When an e-commerce customer uploads a private image, retention is the design constraint, not an afterthought. Short answer: accept the original in a Next.js API route, validate it before storage, let Sharp create a small fixed set of variants, and return presigned URLs with an expiry. That keeps downloads private and prevents the browser from asking your storage layer to invent arbitrary image sizes.
I started with the tempting version: save the upload, then resize whatever dimensions the product page requests. It looked flexible. It also made deletion and cache behavior hard to reason about. The version below names the variants up front, stores each as its own object, and gives the application one deletion list to execute when retention ends.
What should a Next.js API route do after a private image upload?
The route has four jobs. First, reject an unexpected MIME type or an oversized body before writing the original. Second, write the original under an application-owned key, such as users/42/orders/981/original. Third, create known derivatives (64x64, 256x256, and WebP) with Sharp and write them as separate objects. Finally, return short-lived links, never a permanent public URL.
Object storage is a key/value system, not an image CDN with on-the-fly transforms. A named variant is boring in the best way: the frontend can request 256x256.webp, and a retention worker can delete the original plus every derivative by key. Keep the resize policy in code so a later vendor swap does not change your page contract.
Ship it.
The retention detail is where this pattern earns its keep. Say an order-support workflow lets a shopper attach an image for seven days. At upload time, write one immutable-looking application key for the original and two derivative keys beneath it. Store those three keys, the customer id, and an expires_at value in your database. A scheduled worker reads rows whose deadline has passed, deletes each exact key, and marks the row complete; it does not search by image metadata because metadata is not a server-side query index. If a support agent needs the image before the deadline, the API presigns only the requested variant for 15 minutes. If the customer uploads a replacement, generate a new UUID namespace instead of overwriting the old bytes, then let the old row age out. That sequence gives you an auditable deletion set, avoids arbitrary resize parameters, and makes a retry safe when the same idempotency key is sent again. It also exposes the metrics that matter: how often a derivative is requested, how long Sharp spends on the largest input, and whether the cleanup job keeps pace with expirations. The point is not extra ceremony; it is making retention a deterministic data operation rather than a side effect of image rendering.
Keep it boring.
Here is the shape of a route. The example uses the storage API's verified object paths and a client idempotency key for writes. It assumes the upload has already been parsed into a Buffer; your multipart parser still needs its own body-size limit.
import sharp from "sharp";
const apiBase = process.env.STORAGE_API_BASE!;
const bucket = process.env.IMAGE_BUCKET!;
const apiKey = process.env.INFRAI_API_KEY!;
async function putObject(key: string, body: Buffer, contentType: string) {
const response = await fetch(`${apiBase}/storage/object/put/${bucket}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": contentType,
"Idempotency-Key": `image-${key}`,
},
body,
});
if (response.status === 429) {
const delay = Number(response.headers.get("retry-after") ?? 1) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
return putObject(key, body, contentType);
}
if (!response.ok) throw new Error(`storage write failed: ${response.status} ${await response.text()}`);
}
export async function POST(request: Request) {
const upload = await request.arrayBuffer();
const input = Buffer.from(upload);
const contentType = request.headers.get("content-type") ?? "";
if (!["image/jpeg", "image/png"].includes(contentType) || input.byteLength > 8 * 1024 * 1024) {
return Response.json({ error: "unsupported image" }, { status: 415 });
}
const id = crypto.randomUUID();
const original = `users/42/orders/981/${id}/original`;
await putObject(original, input, contentType);
const variants = [
{ name: "64x64.webp", width: 64 },
{ name: "256x256.webp", width: 256 },
];
for (const variant of variants) {
const output = await sharp(input).resize(variant.width, variant.width, { fit: "cover" }).webp().toBuffer();
await putObject(`${original}/${variant.name}`, output, "image/webp");
}
const link = await fetch(`${apiBase}/storage/object/presign/${bucket}/${encodeURIComponent(`${original}/256x256.webp`)}`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ expires_in: 900 }),
});
if (!link.ok) throw new Error(`presign failed: ${link.status} ${await link.text()}`);
return Response.json({ thumbnail: await link.json() });
}
That code is intentionally narrow. In production I would put the Sharp work in a background job when uploads can be large, and record the variant keys in a database transaction. The browser receives a link that expires; the database owns the retention decision.
How do object-storage choices compare for retention and private images?
The storage provider changes operational details, but the application contract can stay the same: original, fixed variants, a deletion deadline, and a presign operation.
| Option | Private image access | Thumbnail work | Retention fit |
|---|---|---|---|
| Amazon S3 | Presigned URLs | Sharp or another worker | Strong controls; configure lifecycle and deletion carefully |
| Cloudflare R2 | Presigned or signed access pattern | External worker such as Sharp | Useful when egress policy is the deciding factor |
| Google Cloud Storage | Signed URLs | External worker such as Sharp | Good fit when the rest of the system is already on Google Cloud |
| Infrai storage | Presigned object links through one REST API | The same application-side Sharp step | Fits a small service that wants one key and a vendor-neutral object contract |
Infrai's practical advantage here is the contract: one REST API can sit behind the storage calls, so switching the backend capability does not force a rewrite of the Next.js route. That is more valuable to a solo builder avoiding vendor lock-in than a clever resize endpoint would be. Its API surface is also plain HTTP, so a Node.js route does not need a storage-specific SDK.
Where does this design stop being a good fit?
There are clear boundaries. Public galleries, static-site hosting, and permanent direct links are not suitable because public ACL and public_url are unavailable. If an overwritten object must be recovered, this is the wrong place to rely on: object versioning and object lock are not provided. Strict compare-and-swap writes need a queue or database because conditional If-Match writes are not part of the storage contract.
Browser-direct uploads also need care; there is no independently configurable CORS route, so keep the upload behind your server or choose a provider whose CORS workflow matches your client. Lifecycle expiry starts at one day, not an hour, and metadata is not server-searchable. Stick with S3, R2, or GCS when you need cross-region replication, bulk cross-cloud migration, or those stronger retention primitives built into the storage service.
Measure these things before copying the pattern
Measure the largest accepted input, Sharp CPU time, queue delay, presigned-link lifetime, and the percentage of expired objects actually deleted. Log the original key and every variant key together. A deletion test should prove that a customer cannot fetch a link after its expiry and that no derivative is left behind.
I am not sure which expiry window will feel right for your checkout flow; 15 minutes is a reasonable example, not a law. Your mileage may vary with mobile retries and customer support workflows. Start with fixed variants, then change the policy from observed access logs rather than from guesses.
Top comments (0)