Skip to main content
Many images per product, uploaded by staff or sellers from an admin UI, shown in order on a public product page, and removed when the product is. Three choices make that work:
  • A unique path per image. uniquePath gives 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, url and sortOrder live in your database. The bucket cannot answer “which images belong to this product”, and it does not have to.
The upload is the same shape as File Attachments: a direct browser upload that your route authorizes, with the product id carried in 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 by sortOrder, and render url straight from them.
app/products/[id]/page.tsx
Reordering is a database update and nothing else. The path and the URL of an image never change, so dragging a thumbnail rewrites 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:
Deleting a product is the same thing in bulk, but the order flips: objects first, rows second. The rows are the only record of the paths, so they have to survive a failed object delete for a retry to find them again, and nothing links a product that is being deleted, so there is no window where a page shows a broken image. If it fails partway, run it again: del treats a missing object as success.
An array is sent as batch deletes, and if any object survives, 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.