Next.js PDF Generation: Invoices, Reports, and Exports
A Next.js PDF generation guide for founders: the three library options, serverless bundle limits, the font trap, and the Claude Code prompt to wire it.
· Justin Boggs

For Next.js PDF generation you have three real choices, and the right one depends almost entirely on where you deploy. Render React components directly to PDF with @react-pdf/renderer if you want a small bundle and predictable output. Drive headless Chromium with Puppeteer or Playwright if you already have an HTML template you don't want to rewrite. Or — and check this first — don't generate the PDF at all, because Stripe already hosts one for every invoice you issue. Most founders reach for Chromium first, blow past a serverless bundle limit, and spend a day on fonts that render as empty boxes.
TL;DR
- If the PDF is a Stripe invoice or receipt, use the
invoice_pdfURL Stripe already generates. Don't build anything.@react-pdf/rendereris the default answer for Vercel and other serverless hosts: no browser binary, deterministic layout, real font control.- Headless Chromium gives you pixel-accurate HTML rendering but needs
@sparticuz/chromium-minon serverless, and cold starts are measured in seconds.- Vercel's standard function bundle limit is 250 MB uncompressed; large functions up to 5 GB are in public beta on Fluid compute as of June 2026.
- Fonts are the number one cause of "it works locally, it's blank in production." Embed them explicitly, always.
Before you build it: does something already have your PDF?
The cheapest PDF is the one you didn't write code for. Three cases where that's true:
Stripe invoices and receipts. Every finalized Stripe invoice carries an invoice_pdf field — a direct link to the PDF — alongside hosted_invoice_url for the payable web page. Both are right there on the Invoice object. If your "generate an invoice PDF" ticket is really "let the customer download their receipt," you are one API read away from done.
One caveat worth knowing before you paste that URL into an email: Stripe's hosted invoice links expire. Fetch the URL from the API when the customer clicks, rather than caching it in your database and mailing it out six months later. Stripe publishes the current expiry window in its support docs, and it has changed before, so treat the URL as short-lived rather than permanent.
If your billing already runs through Stripe, the Customer Portal gives users their own invoice history without you writing a page for it — I covered that setup in adding the Stripe customer portal.
Data exports. If the ask is "let me download my data," a CSV is usually the honest answer and always the cheaper one. PDFs are for documents humans read and file; CSVs are for data humans process. The CSV import and export guide covers that path, and it's a fraction of the work.
Anything that's really a web page. If the customer wants to print it, @media print CSS on the page you already have will get you 80% of the way for zero new dependencies.
That leaves the genuine cases: branded quotes, contracts, monthly PDF reports, certificates, packing slips, anything with a signature block. Those you build.
Which Next.js PDF library should you pick?
There are three approaches, and the decision tree below is how I'd walk it before writing any code.
flowchart TD
A[Need a PDF] --> B{Is it a Stripe invoice<br/>or receipt?}
B -- Yes --> C[Use invoice_pdf from the API<br/>zero code]
B -- No --> D{Do you already have<br/>an HTML template?}
D -- Yes --> E{Deploying to a VPS<br/>or container?}
E -- Yes --> F[Puppeteer or Playwright<br/>full Chromium]
E -- No --> G{Is pixel-accurate HTML<br/>a hard requirement?}
G -- Yes --> H[puppeteer-core plus<br/>sparticuz chromium-min]
G -- No --> I[react-pdf renderer]
D -- No --> I
I --> J[Store the bytes,<br/>return a signed URL]
F --> J
H --> J
React-to-PDF renderers. @react-pdf/renderer is the main one. You write components — Document, Page, View, Text — with a StyleSheet API that looks like a constrained flexbox, and it emits PDF bytes. There's no browser involved. It runs in a plain Node function, the bundle stays small, and the output is deterministic: the same input produces the same bytes every time.
The tradeoff is that it isn't HTML. You can't point it at your existing invoice template and get a PDF out. You're reimplementing the layout in its component API, and its CSS subset is genuinely a subset — no grid, no floats, limited positioning. For a single-column invoice or report that's fine. For a marketing-designed one-pager it's a fight.
Headless browser rendering. Puppeteer and Playwright both let you load an HTML page and call page.pdf(). The output is whatever Chromium renders, which means your existing CSS, your existing template, your existing web fonts. If you already maintain an HTML version of the document, this is the path that doesn't duplicate work.
The tradeoff is that you're shipping a browser. Full Puppeteer bundles Chromium. On a VPS or a container that's a non-issue. On serverless it's the thing you spend the afternoon on, which is the next section.
Low-level PDF libraries. pdf-lib and pdfkit let you draw on a page primitively — place text at coordinates, draw lines, fill an existing PDF form. Nobody should build an invoice this way, but for stamping a page number onto an uploaded PDF, filling a government form, or merging documents, they're exactly right and very small.
The three options side by side
| | @react-pdf/renderer | Puppeteer / Playwright | pdf-lib / pdfkit |
| --- | --- | --- | --- |
| How you author | React components | Your existing HTML + CSS | Coordinates and primitives |
| Browser binary needed | No | Yes | No |
| Serverless-friendly | Yes, out of the box | Only with a min build | Yes |
| Cold start | Fast | Slow (binary download + launch) | Fast |
| Layout fidelity to your site | Reimplemented | Exact | None |
| Font control | Explicit Font.register | Depends on system fonts | Explicit embed |
| Best for | Invoices, reports, statements | Design-heavy documents | Stamping, merging, form fill |
| Worst for | Complex marketing layouts | Tight cold-start budgets | Anything with flowing text |
The short version: @react-pdf/renderer unless you have a specific reason not to. On Vercel, Netlify Functions, Cloudflare, or anywhere else with a bundle ceiling and a cold-start budget, it's the option that doesn't fight the platform. Reach for Chromium when the document's visual design is the product, or when you're on a box you control and reusing an existing template saves you real days.
The serverless gotchas
This is where Next.js PDF generation goes sideways, and all four of these are worth knowing before you start rather than at 1am.
Bundle size. The standard maximum uncompressed size for a Vercel function is 250 MB, and full Puppeteer with a bundled Chromium does not fit. This changed meaningfully in mid-2026: Vercel raised the ceiling to 5 GB on Fluid compute, a 20x increase over the previous 250 MB limit, explicitly citing browser automation dependencies as a target workload. It's a public beta. New projects created after June 30, 2026 are enrolled automatically; existing projects opt in by setting VERCEL_SUPPORT_LARGE_FUNCTIONS=1 and redeploying.
So the "you literally cannot run Puppeteer on Vercel" advice you'll find in older posts is out of date. But bigger bundles still mean slower cold starts, and the lean path is still the better default.
That lean path is puppeteer-core plus @sparticuz/chromium-min. The -min package deliberately ships without the compressed Chromium files. You host those yourself — a bucket, a CDN, a GitHub release — and pass the URL at launch time. Per the project's README, on first run it downloads the pack, untars to /tmp/chromium-pack, and decompresses the binary to /tmp/chromium; on subsequent warm invocations it detects /tmp/chromium already exists and reuses it.
Cold starts. Read that sequence again, because it's the performance model. A cold PDF request downloads a compressed browser over the network, decompresses it, launches it, loads your page, and renders. That is seconds, not milliseconds. Warm requests skip the first two steps and are dramatically faster.
Which means: never make a user wait synchronously on a cold Chromium request. Either generate the PDF in a background job and email or notify when it's ready, or accept a spinner and set expectations in the UI. Background jobs versus cron versus a queue walks through picking the right mechanism, and the Inngest setup guide has the concrete wiring if you go that route.
Timeouts. Serverless functions have execution ceilings. A cold Chromium launch plus a heavy render can eat a surprising fraction of one. Test the cold path deliberately — deploy, wait for the instance to go cold, then hit it. Testing only the warm path is how this ships broken.
Ephemeral disk. /tmp exists and is writable, and it does not persist across cold starts. Fine for the browser binary cache, useless as your PDF store. Generated PDFs go to object storage.
Fonts are the thing that breaks
If exactly one section of this post saves you a day, it's this one.
With Chromium, page.pdf() renders whatever fonts the environment has. Your laptop has hundreds installed. A serverless Linux container has close to none. So your beautifully typeset invoice renders in a fallback serif in production, or — with non-Latin scripts — as rows of empty boxes. The fix is to stop relying on system fonts entirely: embed the font as a base64 @font-face data URI directly in the HTML you hand to Chromium, and wait for document.fonts.ready before calling page.pdf(). Loading a font over the network from inside the render is a race you will occasionally lose.
With @react-pdf/renderer, fonts are explicit and that's a feature. The Fonts documentation covers Font.register({ family, src }), where src is a URL or, in Node, an absolute path. Only TTF and WOFF are supported — OpenType variable fonts don't work, because the PDF 2.0 spec doesn't support them. Register a separate source per weight and style, and the renderer picks the right one per Text element based on its style.
import { Font } from '@react-pdf/renderer'
import path from 'node:path'
Font.register({
family: 'Inter',
fonts: [
{ src: path.join(process.cwd(), 'assets/fonts/Inter-Regular.ttf') },
{ src: path.join(process.cwd(), 'assets/fonts/Inter-Bold.ttf'), fontWeight: 700 },
],
})
Two operational notes. Ship the font files as repo assets so they exist at runtime rather than fetching them over the wire on every cold start. And check the license — plenty of fonts allow web embedding but restrict PDF embedding, and a customer-facing invoice is a distributed document.
If you ever need PDF/A conformance for archival or compliance, every font must be embedded, which rules out relying on system fonts at all.
Wiring it into a route handler
The shape that works, regardless of which renderer you picked:
// /product/lib/pdf/invoice.tsx
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoiceDocument } from './invoice-document'
export async function renderInvoicePdf(invoice: Invoice) {
return renderToBuffer(<InvoiceDocument invoice={invoice} />)
}
// route handler
import { z } from 'zod'
const paramsSchema = z.object({ invoiceId: z.string().uuid() })
export async function GET(req: Request, ctx: { params: Promise<unknown> }) {
const { invoiceId } = paramsSchema.parse(await ctx.params)
const user = await requireUser()
const invoice = await getInvoiceForUser(invoiceId, user.id)
if (!invoice) return new Response('Not found', { status: 404 })
const pdf = await renderInvoicePdf(invoice)
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
'Cache-Control': 'private, no-store',
},
})
}
Four things in that snippet are load-bearing and are the parts an AI assistant will skip unless you ask:
Authorize before you render. getInvoiceForUser(invoiceId, user.id) scopes by owner. A PDF route that takes an ID and renders it is an enumeration vulnerability that leaks other customers' documents. This is the single most common security bug I see in generated PDF endpoints.
Validate the input with Zod before touching the database, same as any other route handler.
Set Content-Disposition or the browser will try to render the bytes inline and you'll get complaints that "download" doesn't download.
Set Cache-Control: private, no-store on anything customer-specific so a shared cache never serves one tenant's invoice to another.
In the Coding Capybaras layout, the render helper lives in /product/lib/pdf/ and the /app/api/ route is a thin shim that calls it. That's the standard product-lib pattern — routes stay a manifest, logic stays testable.
For anything slow or bulk — a month-end run, a 200-page report — invert it. The route enqueues a job, the job renders and uploads to object storage, and the user gets a signed URL. Don't hold an HTTP connection open through a Chromium launch.
The Claude Code prompt
Paste this into Claude Code or Cursor. It's written to produce the safe version rather than the demo version.
Add PDF invoice generation to this Next.js app using
@react-pdf/renderer.Create
/product/lib/pdf/invoice-document.tsxwith aDocumentcomponent that renders: our logo, the invoice number and date, bill-to block, a line-item table with description, quantity, unit price and amount, then subtotal, tax and total. UseFont.registerwith TTF files fromassets/fonts/— do not rely on system fonts.Create
/product/lib/pdf/invoice.tsxexportingrenderInvoicePdf(invoice)that returns a Buffer viarenderToBuffer.Add a route handler at
/api/invoices/[invoiceId]/pdfthat: validates params with Zod, requires an authenticated user, loads the invoice scoped to that user's ID and returns 404 if it doesn't belong to them, renders the PDF, and responds withContent-Type: application/pdf, aContent-Dispositionattachment filename, andCache-Control: private, no-store. Create the route withpnpm new:route— do not edit/app/by hand.Add a test next to the lib that asserts the returned buffer starts with the
Two habits that make prompts like this land: name the file paths, and name the failure you want prevented ("returns 404 if it doesn't belong to them"). Assistants are good at the happy path and will quietly skip authorization unless you make it an explicit requirement. More on that pattern in AI code review for non-technical founders.
If you go the Chromium route instead, the equivalent prompt needs three extra clauses: use puppeteer-core with @sparticuz/chromium-min, host the brotli pack at a URL in an env var, and inline the fonts as base64 @font-face rules and await document.fonts.ready before calling page.pdf().
Frequently asked questions
Can I run Puppeteer on Vercel in 2026?
Yes. The standard 250 MB function limit still applies, but Vercel's large-functions beta raises it to 5 GB on Fluid compute, with browser automation named as an intended workload. The lean puppeteer-core plus @sparticuz/chromium-min setup remains the better default because it keeps cold starts down.
Should I generate PDFs on the client or the server?
Server, for anything that represents a record. Client-side generation means the document depends on the user's browser and fonts, can't be re-issued identically, and can't be stored as the authoritative copy. Client-side is fine for a throwaway "print this view."
Why is my PDF blank or showing boxes in production?
Almost always fonts. Your local machine has the typeface installed and the serverless container doesn't. Embed the font explicitly — Font.register with a bundled TTF, or a base64 @font-face in the HTML you give Chromium.
Where should generated PDFs be stored?
Object storage, with access through short-lived signed URLs. Never /tmp, which disappears on cold start, and never the database as a blob unless the documents are tiny and rare.
How do I add page numbers or a footer?
@react-pdf/renderer has a fixed prop and a render callback that gives you pageNumber and totalPages. With Chromium, pass displayHeaderFooter and a header/footer HTML template to page.pdf().
Is a headless browser a security risk?
It can be, if you render untrusted HTML. Never pass user-supplied HTML straight into the page. Render from your own template with escaped data, and keep the browser off the network with restrictive launch arguments.
Where I'd start
If you're adding Next.js PDF generation this week: check whether Stripe already hosts the document, then reach for @react-pdf/renderer and register your fonts on day one. Only bring in headless Chromium when the visual design genuinely is the deliverable, and when you do, budget an afternoon for cold starts and an hour for fonts. Authorize the route by owner, set the two headers, and push anything slow into a background job before your first real customer finds the timeout.
The Cloudflare R2 integration guide on Coding Capybaras has the prompt for the storage half of this — bucket, signed URLs, and the upload helper the PDF job writes into.