sharp thumbnails in a Next.js route handler, and where the time goes
Two config flags sharp needs in App Router, a measured split between resize CPU and upload latency, and the private-storage calls that back the whole flow.
Put export const runtime = "nodejs" at the top of the route file, add sharp to serverExternalPackages in next.config.mjs, write the original to a private bucket with PUT /v1/storage/object/put/{bucket}/{key}, and move the resize into after() so the user isn’t waiting for it. Infrai’s storage API is plain REST, so the handler needs no storage SDK on top of sharp.
That ordering is deliberate. We timed each step against a 4032×3024 phone-sized JPEG on 2026-07-26, and the resize turned out to be the cheap part — the object write dominated by two orders of magnitude. Most guides optimise the wrong half.
Two flags before any of this compiles
sharp is a native addon. It cannot load on the Edge runtime, it cannot load in middleware, and the App Router bundler will try to trace its platform-specific binary into your output unless you tell it not to.
// next.config.mjs
const nextConfig = {
serverExternalPackages: ["sharp"],
experimental: {
// route handlers that accept a multi-megabyte upload
serverActions: { bodySizeLimit: "10mb" },
},
};
export default nextConfig;
Without the first line you get a runtime error about a missing sharp-linuxmusl-x64 binary the first time the route executes in a container — never locally, which is what makes it annoying. Without runtime = "nodejs" on the route itself you get a build-time failure the moment anything in the import graph touches a native module.
The route handler that only does what the user waits for
// app/api/uploads/route.ts
import { after } from "next/server";
import { NextResponse } from "next/server";
import { deriveThumbnails } from "@/lib/derive";
export const runtime = "nodejs";
export const maxDuration = 60;
const API = "https://api.infrai.cc";
const BUCKET = "kb-nextjs-thumbs";
export async function POST(request: Request): Promise<NextResponse> {
const key = process.env.INFRAI_API_KEY;
if (!key) return NextResponse.json({ error: "missing key" }, { status: 500 });
const form = await request.formData();
const file = form.get("file");
const owner = String(form.get("owner") ?? "");
if (!(file instanceof File) || !owner) {
return NextResponse.json({ error: "file and owner required" }, { status: 400 });
}
const uploadId = crypto.randomUUID().slice(0, 6);
const objectKey = `u/${owner}/original/${uploadId}.jpg`;
const bytes = Buffer.from(await file.arrayBuffer());
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
method: "PUT",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({
data_base64: bytes.toString("base64"),
content_type: file.type || "image/jpeg",
metadata: { "owner-id": owner, "upload-id": uploadId },
}),
});
if (!res.ok) {
return NextResponse.json({ error: "upload failed" }, { status: 502 });
}
after(() => deriveThumbnails({ bucket: BUCKET, owner, uploadId, bytes }));
return NextResponse.json({ uploadId, key: objectKey }, { status: 201 });
}
after() runs its callback once the response has been flushed, and it’s available from Next.js 15 onward. The buffer is already in memory, so handing it to the callback costs nothing extra — but it does mean the function holds that memory until the derivation finishes, which is the trade-off you’re accepting in exchange for not blocking the response.
Where the time actually goes
| Step | Input | Measured | Notes |
|---|---|---|---|
| sharp decode + resize to 320px WebP | 6.4 MB JPEG | 56 ms | output 7,438 bytes |
| sharp decode + resize to 800px WebP | 6.4 MB JPEG | 87 ms | output 27,498 bytes |
| sharp decode + resize to 1600px WebP | 6.4 MB JPEG | 234 ms | output 277,584 bytes |
| write the original object | 6.4 MB → 8.6 MB base64 | 13.9 s | one billable call |
| write the 320px thumbnail | 7 KB | 2.6 s | one billable call |
Read the last two rows again.
Three resizes cost 377 ms of CPU between them; a single upload of the original cost 13.9 seconds of wall clock, almost all of it pushing base64 over the wire. Deferring the sharp call is worth doing, but deferring it saves you a third of a second. What actually decides whether the user stares at a spinner is how many object writes sit inside the request — which is why the handler above does exactly one, and the derivative writes happen after the 201 has already gone out.
The derivative pass
// lib/derive.ts
import sharp from "sharp";
const API = "https://api.infrai.cc";
const SIZES = [320, 800, 1600] as const;
type DeriveArgs = { bucket: string; owner: string; uploadId: string; bytes: Buffer };
export async function deriveThumbnails({ bucket, owner, uploadId, bytes }: DeriveArgs): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is not set");
for (const width of SIZES) {
const out = await sharp(bytes)
.rotate()
.resize({ width, height: width, fit: "inside", withoutEnlargement: true })
.webp({ quality: 78 })
.toBuffer();
const key = `u/${owner}/t${width}/${uploadId}.webp`;
const res = await fetch(`${API}/v1/storage/object/put/${bucket}/${key}`, {
method: "PUT",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
data_base64: out.toString("base64"),
content_type: "image/webp",
cache_control: "private, max-age=604800",
metadata: { "owner-id": owner, "derived-from": uploadId },
}),
});
if (!res.ok) throw new Error(`derive ${width} failed: HTTP ${res.status}`);
}
}
.rotate() with no argument applies the EXIF orientation tag and then strips it, which is the one-line fix for portrait photos arriving sideways; skip it and roughly half your iPhone uploads land rotated 90 degrees, because the camera writes the sensor orientation into metadata rather than into the pixels and sharp will happily preserve the raw pixel order. withoutEnlargement stops a 200px avatar being blown up to 1600px and stored at ten times its useful size — the derived object is then bigger than the original, which is the sort of thing you notice three months later on a storage bill. Metadata keys have to be hyphenated; an underscore anywhere in a key name fails the write with a 503 instead of a validation error.
The loop is sequential on purpose.
Confirming the objects exist
export INFRAI_API_KEY=your_infrai_api_key
curl -s "https://api.infrai.cc/v1/storage/object/list/kb-nextjs-thumbs?prefix=u/acct-5518/&limit=10" \
-H "Authorization: Bearer $INFRAI_API_KEY"
{
"ok": true,
"data": {
"items": [
{
"key": "u/acct-5518/original/p-8d3a.jpg",
"size_bytes": 6424541,
"etag": "59639ec43fcada5a2ca8ca5710e2f89c",
"content_type": null,
"metadata": null,
"last_modified": "2026-07-26T05:52:05Z"
},
{
"key": "u/acct-5518/t320/p-8d3a.webp",
"size_bytes": 7438,
"etag": "2b125414282301ab30ff08565eb3fcb5",
"content_type": null,
"metadata": null,
"last_modified": "2026-07-26T05:52:08Z"
}
],
"next_cursor": null
}
}
Listing returns content_type: null and metadata: null for every item — those fields are only populated by GET /v1/storage/object/head/{bucket}/{key}. If your gallery page needs the owner tag, it’s a head call per object, not a field you can read off the listing.
curl -s "https://api.infrai.cc/v1/storage/object/head/kb-nextjs-thumbs/u/acct-5518/t320/p-8d3a.webp" \
-H "Authorization: Bearer $INFRAI_API_KEY"
Getting the private thumbnail onto the page
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-nextjs-thumbs/u/acct-5518/t320/p-8d3a.webp" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"download","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-nextjs-thumbs/u/acct-5518/t320/p-8d3a.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=bfbbb5f5fa0176fc5a3c79480ed3537741e1f204b342b265461dceffd93b9844",
"expires_at": "2026-07-26T06:02:26.660638Z"
}
}
Here’s the caveat that catches people building a gallery: every object read from this storage layer carries Content-Disposition: attachment and an x-amz-force-download header, so dropping that URL into <Image src> downloads the file instead of showing it. For an on-page thumbnail you need a Next.js route that fetches the bytes server-side and re-emits them with your own headers, or a public CDN in front — the signed URL alone won’t do it.
Where this shape stops working
Hosted platforms cap the request body of a serverless function in the single-digit megabytes; Vercel’s limit is 4.5 MB. A 6.4 MB phone photo therefore never reaches your route handler at all on that deployment target, and no amount of bodySizeLimit tuning changes a platform limit. Self-hosted Next.js behind your own Node server has no such ceiling, which is why the same code works on a VPS and 413s on a serverless deploy.
That’s a deployment-target decision, not a code one.
The usual escape is a browser-direct upload straight to storage, and that’s the one thing this API can’t back: bucket records carry a cors_rules array but there’s no route to set it, so a cross-origin PUT from the browser will be blocked. If your users upload full-resolution photos, put Cloudflare R2 or Amazon S3 on the ingest leg — both have a CORS setter and both are cheap. Cloudinary is the other honest answer if you’d rather not run sharp at all and can accept per-transformation pricing.
What the pipeline costs
curl -s "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).capabilities.filter(c=>c.id.startsWith("storage.")).map(c=>c.id+" "+(c.billing.is_billable?"$"+c.billing.price_usd:"free")).join("\n")'
Verified 2026-07-26: object writes bill at $0.0001 per call, so one original plus three renditions is four writes — $0.0004 per upload — while list, head, presign and bucket management are free but rate-limited, and a new account starts with $2 of credit. Storage and egress are metered separately on top. Run the lookup above rather than trusting this paragraph; per-call rates on this platform have moved downward over time and discounts run, so the live figure is likely at or below what we measured.
The durable point isn’t the rate. It’s that the same key doing these writes also runs the queue you’ll want when derivation outgrows after(), the cron sweep that expires old renditions, and the error capture that tells you a resize threw — none of which is another vendor, another SDK or another invoice.