Skip to main content

Storage Config

Choose where uploaded images go — Supabase or any S3-compatible bucket — and what happens to them on the way in: compression, size and type limits, and the caching that follows.

File: config/storage.config.ts

This file owns two decisions: where an uploaded file lands, and what happens to it on the way there. Both used to be code.

Staying on Supabase

Nothing to do. provider: 'supabase' is the default, files go to the Supabase project you are already connected to, into the bucket named by SUPABASE_S3_BUCKET (default storage), and existing installs behave exactly as they did before this config existed.

Moving to R2, B2, Wasabi, MinIO or AWS

config/storage.config.ts
export const storageConfig = defineStorage({
  provider: 's3',
});

Then fill in the environment:

VariableValue
STORAGE_S3_ENDPOINThttps://<account-id>.r2.cloudflarestorage.com
STORAGE_S3_REGIONauto on R2; a real region on AWS
STORAGE_S3_ACCESS_KEY_ID / STORAGE_S3_SECRET_ACCESS_KEYFrom your provider
STORAGE_S3_BUCKETBucket name
STORAGE_S3_PUBLIC_URLThe domain files are served from
STORAGE_S3_FORCE_PATH_STYLEtrue for MinIO and similar self-hosted gateways. R2 and AWS do not want it.
The public URL is not the endpoint

The endpoint is where files are written; STORAGE_S3_PUBLIC_URL is where they are read — on R2 that is your public bucket domain or a custom domain. Leave it unset and uploads succeed while every image 404s. Whatever domain you serve from must also be allowed in next.config.ts under images.remotePatterns.

Uploading a file does not make it readable

Public readability is a bucket setting, and the driver deliberately sends no ACL, because no single setting works everywhere: R2 has no object ACLs, and AWS rejects an upload that carries one. Miss this step and every upload succeeds while every image 403s. UPGRADING.md in your repo has the exact steps for Cloudflare R2, AWS S3, Backblaze B2, Wasabi and MinIO, plus a one-line check to confirm it worked.

Switching providers does not migrate existing files: new uploads go to the new bucket while old URLs keep pointing at the old one, which keeps working because stored URLs are absolute.

Compression on the way in

image: {
  optimize: true,        // false stores the original bytes untouched
  format: 'webp',        // 'webp' | 'avif' | 'jpeg' | 'original'
  quality: 70,           // 1–100; 70 is visually indistinguishable for photos
  maxDimension: 2560,    // longest edge in pixels; null never resizes
}

A 32 MB photo from a phone is stored as roughly 1.4 MB. Animated images are converted too, and are kept as they were if the frames do not survive the encode.

Why on the way in, not at render time

next/image optimizes for visitors who arrive through next/image. Compressing on upload means the small file is what every consumer gets — browsers, RSS readers, scrapers, the Open Graph crawler — and it is what your storage bill is calculated from.

Limits

maxFileSizeMb: 8,
allowedTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'],
pathPrefix: 'uploads',   // uploads/<uuid>.webp

Both are read by the API and by every picker in the interface, so what the UI promises is what the server enforces. The default cap is 8 MB, up from 1 MB — safe now that files shrink on arrival.

There is also a limit you cannot configure

A file's size does not bound what decoding it costs: a 0.74 MB PNG can carry 256 megapixels, because flat regions compress to almost nothing. Measured on this pipeline that single file cost 240 MB of memory, and four at once cost 895 MB — enough to exhaust a 1 GB serverless function on 3 MB of upload traffic. Images are therefore capped at 64 megapixels per frame and 128 across an animation (comfortably clear of a 48 MP phone camera), and no more than two decode at once. Past the limit a caller gets a 400 naming the dimensions; while the server is saturated, a 503 asking them to retry.

Caching

Uploaded images are stored with a one-year cache lifetime on both drivers. That is correct rather than aggressive: object keys contain a UUID and are written with upsert: false, so the bytes behind a URL never change — re-uploading produces a new key, a new URL and an updated row pointing at it.

If you have been overwriting objects by hand, stop first

Supabase uploads used to be stored with max-age=3600 while the S3 driver used a year, so changing provider moved your egress bill by a factor of 24 without saying so. Both are a year now — which also means a CDN will hold your existing images far longer than it used to.

next/image

next.config.ts derives your own upload hosts from the environment (plus the Printful CDN for the store vertical), so enabling optimization cannot break your own images the way it used to when the Supabase bucket was a commented-out placeholder. Optimization itself stays off by default; the trade-off is written next to the switch in next.config.ts.

Next: Environment Variables · What to Customize