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
export const storageConfig = defineStorage({
provider: 's3',
});Then fill in the environment:
| Variable | Value |
|---|---|
STORAGE_S3_ENDPOINT | https://<account-id>.r2.cloudflarestorage.com |
STORAGE_S3_REGION | auto on R2; a real region on AWS |
STORAGE_S3_ACCESS_KEY_ID / STORAGE_S3_SECRET_ACCESS_KEY | From your provider |
STORAGE_S3_BUCKET | Bucket name |
STORAGE_S3_PUBLIC_URL | The domain files are served from |
STORAGE_S3_FORCE_PATH_STYLE | true for MinIO and similar self-hosted gateways. R2 and AWS do not want it. |
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.
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.
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>.webpBoth 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.
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.
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.