PDF options in a Next.js app
A Next.js project can create PDFs in the browser, inside its own server functions, or by handing the work to a service. The right answer depends on where you deploy. On a long running Node server you can host almost anything; on Vercel or another serverless host, every dependency has to fit inside a function bundle that starts cold.
| Approach | Where it runs | Trade off |
|---|---|---|
@react-pdf/renderer | Server or browser, using its own React primitives | Fast and pure JavaScript, but you lay out pages with its components and a subset of CSS rather than your normal HTML |
| Puppeteer with full Chromium | A Route Handler on the Node.js runtime | The browser download is far larger than the bundle limits of most serverless hosts |
puppeteer-core plus a trimmed Chromium build | Serverless functions | Fits, but you match browser and library versions yourself and pay for slow cold starts |
Browser print (window.print()) | The visitor’s browser | No server work, but every browser paginates differently and you cannot store or email the result |
| CastPDF | Any runtime that has fetch | One HTTPS call per document and a plan above the free allowance |
The serverless point deserves a closer look. The regular puppeteer package downloads a complete Chromium build when it installs, and that alone exceeds the unzipped size most platforms allow for one function. The usual workaround is a compressed Chromium made for Lambda style environments, which then decompresses into temporary storage on every cold start. It works, yet every Next.js or Node upgrade becomes a small compatibility project. Calling an API keeps your bundle as small as the code you wrote.
A Route Handler that returns the PDF
Create a test key in the dashboard (it starts with cpdf_test_) and put it in .env.local as CASTPDF_API_KEY, next to the ID of the invoice starter you copied. The handler below reads an invoice number from the query string, loads the record from your own data layer, renders it through the saved template and streams the result straight back to the browser.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { findInvoiceForCurrentUser } from '@/lib/invoices';
export const runtime = 'nodejs';
export const maxDuration = 60;
export async function GET(request: NextRequest) {
const number = request.nextUrl.searchParams.get('number');
if (!number) {
return NextResponse.json({ message: 'Add ?number=' }, { status: 400 });
}
// Returns null when the signed-in user does not own this invoice.
const invoice = await findInvoiceForCurrentUser(number);
if (!invoice) {
return NextResponse.json({ message: 'Not found' }, { status: 404 });
}
const upstream = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `invoice-${invoice.number}`,
},
body: JSON.stringify({
template_id: process.env.INVOICE_TEMPLATE_ID,
data: invoice,
filename: `invoice-${invoice.number}`,
}),
signal: AbortSignal.timeout(60_000),
});
if (!upstream.ok) {
const { error } = (await upstream.json()) as { error: { code: string; message: string } };
console.error('CastPDF failed', error.code, error.message);
return NextResponse.json({ message: 'The PDF could not be created' }, { status: 502 });
}
return new Response(upstream.body, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
'Cache-Control': 'private, no-store',
},
});
}A few details matter here. runtime = 'nodejs' keeps the handler on the full Node runtime, and maxDuration asks the host for 60 seconds instead of its shorter default (the ceiling depends on your hosting plan). Passing upstream.body to new Response streams the PDF through without buffering it in memory. The ownership check comes before the API call, so nobody can download another customer’s invoice by guessing numbers, and a failed upstream call becomes a clean 502 for your UI instead of a leaked error message.
Link to it from any page with a plain anchor such as /api/invoice?number=INV-2026-0311. Use attachment in the Content-Disposition header to force a download, or inline to open the PDF in a new tab.
The Server Action alternative
A Server Action cannot hand a binary file to the browser, because its return value has to be serialisable. What it can return is a signed link. Ask CastPDF for "response": "url" and the reply is JSON with a private download address that works without your key until it expires. This suits a button like “Download confirmation” on an order page: the action runs on the server, the client receives a short object and opens the link.
'use server';
import { getOrder } from '@/lib/orders';
type Result = { ok: true; url: string; expiresAt: string } | { ok: false; code: string };
export async function createOrderConfirmation(orderId: string): Promise<Result> {
const order = await getOrder(orderId);
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `order-confirmation-${order.number}`,
},
body: JSON.stringify({
template_id: process.env.ORDER_CONFIRMATION_TEMPLATE_ID,
data: {
order: { number: order.number, placed: order.placedAt, delivery: order.deliveryWindow },
customer: { name: order.customerName, email: order.customerEmail },
lines: order.lines,
totals: order.totals,
},
filename: `order-${order.number}`,
response: 'url',
}),
signal: AbortSignal.timeout(60_000),
});
const body = await res.json();
if (!res.ok) return { ok: false, code: body.error.code };
return { ok: true, url: body.url, expiresAt: body.expires_at };
}Returning a result object instead of throwing is deliberate. In production builds Next.js replaces the message of an error thrown in a Server Action with a generic one, so the client would never learn why it failed. With the union type above, your client component can show “Try again in a minute” for rate_limited and something more specific for the rest. On success, set window.location.href to the returned url, or store it alongside the order so the customer finds it again later.
The template and the JSON it receives
Keep the order confirmation layout in a saved template rather than in your React tree. You write its HTML and CSS once in the dashboard with a live preview; after that, a colleague can adjust the sample values in Simple mode without opening your repository. Your action sends only the facts that change. This is the request body the action above produces for a typical order:
{
"template_id": "6b0f3c2e-9d41-4a7e-8f15-2c3d4e5f6a7b",
"data": {
"order": { "number": "ORD-48213", "placed": "2026-10-02", "delivery": "6 to 8 October" },
"customer": { "name": "Priya Raman", "email": "[email protected]" },
"lines": [
{ "sku": "LMP-220", "name": "Brass desk lamp", "quantity": 1, "price": 129 },
{ "sku": "BLB-E27", "name": "Warm LED bulb, 2 pack", "quantity": 2, "price": 12.5 }
],
"totals": { "subtotal": 154, "shipping": 0, "tax": 30.8, "total": 184.8 }
},
"filename": "order-ORD-48213",
"response": "url"
}Inside the template, Liquid fills the values: {% for line in lines %} builds the item rows and {{ totals.total | money: "EUR" }} formats the amount. The data object replaces the template’s sample data entirely, so send every field the layout reads. Templates render in print mode by default, which keeps a long order table tidy across pages with its header repeated.
Next.js production checklist
- Never prefix the key with
NEXT_PUBLIC_. That prefix inlines a variable into the client JavaScript, where anyone can read it. PlainCASTPDF_API_KEYstays on the server. - Add
import 'server-only'at the top of the module that calls CastPDF. If a client component ever imports it by mistake, the build fails instead of shipping your key. - Use the test key for preview deployments and the
cpdf_live_key only in the production environment. Most hosts let you set a different value per environment, and a redeploy picks up the change. - Set
maxDurationto at least 60 seconds on routes that render long reports. A render may take up to 30 seconds before CastPDF stops it. - Do not fetch your own Route Handler from a Server Component. Import the function that calls CastPDF and run it directly; the extra HTTP hop only adds latency and another timeout.
- Build the
Idempotency-Keyfrom the invoice or order number. A double click, or a retry after a timeout, then returns the first document instead of making and billing a second one. - Mind the document limits of 50 pages and 40 MB. Product photos are the usual reason an order PDF grows, so serve them at print size.
Troubleshooting Next.js PDF routes
| Symptom | Likely cause | Fix |
|---|---|---|
The key is undefined in your component | The code runs in a client component, where server variables do not exist | Move the call into a Route Handler or a Server Action |
Works locally, invalid_api_key (401) after deploy | The variable is missing in that environment, or was added after the last build | Set it for the environment and redeploy |
FUNCTION_INVOCATION_TIMEOUT on Vercel | The function limit is shorter than the render | Raise maxDuration, or move very large reports to a background worker |
| The downloaded file will not open | The handler returned JSON or text, or used res.text() on the PDF | Return upstream.body or the arrayBuffer() with the PDF content type |
template_render_error (422) | A Liquid tag in the template failed on the data you sent | Read details.line and details.column, then fix the tag or the field |
rate_limited (429) during preview testing | Test keys allow 20 requests per minute | Wait for the Retry-After seconds; live keys have higher limits |
Start with the quickstart to create a key and a template, and keep the API reference open for every request field. The Node.js page covers the same calls outside a framework, and the Express page shows them in a classic route with error middleware.