PDF generation in Next.js
A route handler returns a Web `Response`, so the PDF can be piped straight through without ever touching the filesystem — which matters because on Vercel there is no writable filesystem to touch.
The code
// app/api/invoice/[id]/route.ts
import { NextResponse } from 'next/server';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const html = await renderInvoiceHtml(id); // your existing template
const upstream = await fetch('https://api.pdfcraft.dev/v1/render', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PDFCRAFT_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
html,
options: {
printBackground: true,
margin: { top: '20mm', bottom: '20mm' },
footerHtml:
'<div style="font-size:9px;width:100%;text-align:center">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
},
}),
});
if (!upstream.ok) {
const { error } = await upstream.json();
return NextResponse.json({ error }, { status: upstream.status });
}
// Stream it through. Nothing is buffered in the function.
return new Response(upstream.body, {
headers: {
'content-type': 'application/pdf',
'content-disposition': `attachment; filename="invoice-${id}.pdf"`,
},
});
}What to watch for
- Keep the key server-side. `NEXT_PUBLIC_` is not configuration — it is a literal substituted into the client bundle at build time, so a key behind that prefix ships to every visitor in the JavaScript.
- Returning `upstream.body` streams it; `await upstream.arrayBuffer()` buffers the whole PDF in the function first and counts against the memory limit for nothing.
- The Edge runtime has no `fs`, which is fine here — but it also has a shorter execution ceiling than Node. A 30-second render belongs on the Node runtime.
Why not run the browser yourself
You can. Puppeteer and Playwright both work, and for a script you run by hand they are the right answer. The cost arrives at the point it becomes a service someone depends on: one long-lived browser per process rather than one per request, a semaphore so four renders do not become forty, a hard timeout that force-closes a hung page before it holds a slot forever, a scheduled restart because Chromium leaks, and a Chromium version you are now responsible for patching.
None of that is hard. All of it is work that has nothing to do with your product, and it fails under concurrency rather than in testing — which is the worst time to discover it.
Other frameworks
- PDF generation in Django
- PDF generation in Ruby on Rails
- PDF generation in Laravel
- PDF generation in AWS Lambda
- PDF generation in Cloudflare Workers
- PDF generation in Supabase Edge Functions
- PDF generation in Spring Boot
- PDF generation in FastAPI
- PDF generation in Express
- PDF generation in ASP.NET Core
- PDF generation in n8n