- A unique path per image.
uniquePathgives every upload its own object, so replacing an image is a new object at a new path and never an overwrite. cache: 'immutable'. Nothing is ever rewritten at a path, so every image can be cached for a year and a cached page can never show an image that has since changed.- A row per image.
productId,path,urlandsortOrderlive in your database. The bucket cannot answer “which images belong to this product”, and it does not have to.
input.
This recipe uses a public bucket, since product pages link to the images directly. If you have not created one yet, start with the Quickstart.
The handler
The route checks that this user may edit this product, then names a fresh path for the image.lib/uploads.ts
cache: 'immutable' is safe here only because the path is unique. Swapping an image out means uploading a new one and deleting the old row, never writing over the object a page is already linking to. The other two cache shapes are in Caching.
sortOrder is a timestamp rather than a count, so three files uploading at once, or one completion request retried, cannot land on the same number.
A throw out of onUploadComplete deletes the object, so the catch turns a database failure into a refusal the user can retry from. not_ready is the 503 code, the one that means try again. See Upload handler and Errors.
The route
Mount the handler, then bind the hooks to it.app/api/upload/route.ts
lib/upload-hooks.ts
The admin uploader
One picker, many files.input is required by the hook because the route declares a schema, so a missing productId fails to compile.
components/product-image-uploader.tsx
accept comes from the route’s constraints, so the file dialog only offers images.
The product page
Read the rows, sorted bysortOrder, and render url straight from them.
app/products/[id]/page.tsx
sortOrder on a few rows and uploads nothing.
app/actions.ts
Deleting
Delete the row first, then the object. The page stops showing the image immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image.del treats an already missing object as success, so it is safe to retry.
Add these to app/actions.ts:
del treats a missing object as success.
del throws partial_delete with the remaining paths in failed. See Deleting.
Next steps
Caching
immutable, revalidate, and the versioned URL pattern in full.Upload handler
uploadRoute, input, state, and multiple routes on one endpoint.Deleting
One path, an array, a prefix, and what a partial delete reports.
Profile pictures
One image per row, overwritten in place.