Next.js route handler: private PDF export with an expiring download URL
A working App Router export endpoint: render the PDF, store it in a private bucket over REST, mint a short-lived link, and what the US/EU answer really is.
An export endpoint in the App Router has three jobs: produce the PDF bytes, park them somewhere the public can’t enumerate, and give the browser a URL that stops working. Infrai covers the last two with two REST calls — PUT /v1/storage/object/put/{bucket}/{key} writes the file, POST /v1/storage/object/presign/{bucket}/{key} mints the link — so your route handler stays glue code with no storage SDK compiled into the bundle.
The US/EU half of the question has a less comfortable answer, and it’s the section most people should read first. We’ll get there.
Write the PDF into a private bucket
Buckets are private, and acl accepts only private — there’s no public-bucket mode to forget to turn off. Create one per data class rather than one per customer; keys carry the tenancy.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "kb-pdfexport-0726", "acl": "private"}'
The write itself takes the bytes base64-encoded in a JSON body, alongside the content type. Build the payload in a file rather than inline — a 4 MB PDF becomes about 5.5 MB of base64 and blows past most shells’ argument limits:
PDF_B64=$(base64 < invoice-2026-07.pdf | tr -d '\n')
printf '{"data_base64":"%s","content_type":"application/pdf"}' "$PDF_B64" > payload.json
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-pdfexport-0726/exports/2026-07/tenant_88/invoice-2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @payload.json
{
"ok": true,
"data": {
"bucket_id": "bkt_0ba92a278c314607963bbd",
"key": "exports/2026-07/tenant_88/invoice-2026-07.pdf",
"size_bytes": 31,
"etag": "139ae2006aeea6335ef3d631db69fa45",
"content_type": "application/pdf",
"metadata": null
},
"metadata": { "request_id": "req_5f0c2d9b4b1e4f0a9d5c1a77", "latency_ms": 214 }
}
Slashes in the key are yours to use freely; there are no directories underneath, just a flat namespace where exports/2026-07/tenant_88/ is a prefix you can list and expire as a unit. One caveat we hit in testing on 2026-07-26: if you attach an idempotency_key and then reuse it for different bytes, the second write returns ok: true while echoing the first object’s size and etag. Derive that key from a content hash, or leave it off for exports.
Mint the link, and cap its life
expires_seconds is bounded to 1..604800 — seven days, the same ceiling SigV4 imposes everywhere. Anything outside it is rejected with STORAGE_INVALID_TTL before signing happens.
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-pdfexport-0726/exports/2026-07/tenant_88/invoice-2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op": "get", "expires_seconds": 600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-pdfexport-0726/exports/2026-07/tenant_88/invoice-2026-07.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260726T005736Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=377e98db1c97afee4f46d3d9739944d302b6c6e57a72d65a51c8855db186c744",
"expires_at": "2026-07-26T01:07:36.522297Z"
}
}
op takes get or put. Pass anything else and you quietly receive a download URL instead of an error, which is a nasty way to discover your upload flow was never signed for upload — worth flagging, because the response looks successful.
The route handler
Two handlers, not one. The POST builds and stores the artefact; the GET checks ownership, confirms the object exists and returns a fresh URL each time it’s called. Nothing is cached, because a cached signed URL is a leaked one.
// app/api/exports/[id]/route.ts — Next.js 15, App Router
import { NextResponse } from "next/server";
const BASE = "https://api.infrai.cc";
const BUCKET = "kb-pdfexport-0726";
const KEY = process.env.INFRAI_API_KEY;
type Ctx = { params: Promise<{ id: string }> };
async function infrai(method: string, path: string, payload?: unknown) {
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const res = await fetch(`${BASE}${path}`, {
method,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: AbortSignal.timeout(20000),
cache: "no-store",
});
const json = await res.json();
if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.data;
}
export async function GET(req: Request, ctx: Ctx) {
const { id } = await ctx.params;
const tenant = req.headers.get("x-tenant-id");
if (!tenant) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
const objectKey = `exports/2026-07/${tenant}/${id}.pdf`;
const head = await infrai("GET", `/v1/storage/object/head/${BUCKET}/${objectKey}`);
if (!head.found) return NextResponse.json({ error: "export not ready" }, { status: 404 });
const link = await infrai("POST", `/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
op: "get",
expires_seconds: 600,
});
return NextResponse.json({ url: link.url, expires_at: link.expires_at, bytes: head.size_bytes });
}
The ownership check happens before the presign call and never gets memoised. That ordering is the whole security model of the endpoint, and it belongs in your code — not in the URL.
A signature is delivery, not authorization
This is where the AWS-shaped guides tend to hand-wave. A presigned URL is a bearer token pasted into a query string; anyone holding it holds the object, and no storage service can tell your customer apart from whoever they forwarded the email to. Treat it as a convenient way to move bytes off your server, not as an entitlement system.
For genuinely sensitive documents — signed contracts, medical records, anything with a regulator attached — proxy the bytes through your own authenticated route and read the object server-side with GET /v1/storage/object/get/{bucket}/{key}. You pay a little latency and keep the access decision inside your app. And verify for yourself how the object host answers an unsigned request before you lean on a signature as a boundary; that’s a five-minute check that saves an awkward conversation later.
Verify it worked
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-pdfexport-0726/exports/2026-07/tenant_88/invoice-2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
found is a boolean inside a 200 response, not an HTTP status — so res.ok tells you nothing here. A missing bucket surfaces as STORAGE_BUCKET_NOT_FOUND, a missing key as found: false.
The US/EU question, answered honestly
Bucket creation accepts a region and reports it back, but placement isn’t guaranteed by that field: a bucket we created as eu-central-1 on 2026-07-26 returned a presigned host in ap-singapore, and the SigV4 credential scope agreed. If a customer contract names a jurisdiction, don’t infer residency from the region string — check the host in the presign response, or stick with S3 or Cloudflare R2, where you choose the region and the endpoint proves it. That’s a real limitation and the honest reason to pick a specialist for regulated document workloads.
| Infrai storage | S3 + AWS SDK v3 | Cloudflare R2 | Supabase Storage | |
|---|---|---|---|---|
| Code in the route handler | two fetch calls | SDK client + presigner package | S3-compatible SDK | JS client |
| Public buckets | not supported | supported | supported | supported |
| Max link TTL | 7 days | 7 days | 7 days | configurable |
| Region you can prove | verify the host | yes | yes | yes |
| Browser-direct upload | no bucket CORS route yet | yes | yes | yes |
| Same key also runs | PDF render, email, cron, queues, AI | S3 only | R2 only | Postgres + auth |
What it costs, and how to check today’s number
Buckets, presign, head and list are free and rate-limited. Writes bill at $0.0001 per storage.object.put call, verified 2026-07-26 against real metered usage, and new accounts start with $2 of credit. Reads are cheaper than the published per-call rate suggests, so read your own meter rather than a rate card:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.breakdown[] | select(.key | startswith("storage."))'
Rates drift downward and discount campaigns run, so the number you get back may well be lower than the one printed here. The structure is the durable part: bucket and link operations free, object writes metered per call, stored bytes metered separately.
The argument for putting an export flow here isn’t the rate. It’s that the same key already renders the PDF, queues the job, emails the “your export is ready” message and captures the failure when the render dies — four accounts collapsed into one invoice. If exports are genuinely all you need, a specialist bucket is a fine answer and nobody should talk you out of it.