Guides / Next.js invoices
Generate PDF invoices in Next.js
By the end of this you'll have GET /api/invoices/[id]/pdf in a Next.js App Router app,
returning a real invoice PDF built from your own data. No browser binary in your deployment, no
outputFileTracingIncludes gymnastics, and nothing that behaves differently on Vercel than it
did locally.
If you arrived here from a bundle-size error, read Running Puppeteer on Vercel and Lambda first. It's the honest version of the road you were on.
What you need
- A Next.js app on the App Router (this works the same in a Pages Router API route, and the handler signature differs).
- A PDFglyph account. The Free plan covers building this.
- An API key (dashboard → API keys, shown once) and a published template with its
tpl_code.
The fastest way to the template: open Modern Invoice in the gallery, hit
Use this template, and it's cloned into your account with working sample data. Publish it,
then copy the tpl_ code from the editor's API tab. A template must be published before the
API will render it. An unpublished one returns template_not_published.
1. Install the SDK
npm install pdfglyphZero dependencies, built on the platform fetch. See the TypeScript SDK reference
for the full surface.
2. Keep the key server-side
Add the key to .env.local, without the NEXT_PUBLIC_ prefix, so it can never be inlined
into browser JavaScript:
PDFGLYPH_API_KEY=sb_your_key_here
PDFGLYPH_TEMPLATE_ID=tpl_invoice_8x2kThen create the client once, in a module the client bundle cannot reach:
// lib/pdfglyph.ts
import 'server-only';
import { PdfGlyph } from 'pdfglyph';
// Stateless and connection-free, so one instance per process is all you need.
export const pdfglyph = new PdfGlyph({ apiKey: process.env.PDFGLYPH_API_KEY! });
The server-only import turns an accidental import from a client component into a build error
instead of a leaked key. Next resolves it with nothing extra to install (verified on 15.5).
If you skip it, skip it deliberately.
3. Map your data to the template's variables
The gallery invoice expects invoiceNumber, issueDate, dueDate, company, billTo, an
items array, subtotal, taxLabel, taxAmount, total, payment, and an optional notes.
Your database has none of those names, and its money is in cents.
Do the mapping (and all formatting) in one function, on the server:
// lib/invoice-pdf-data.ts
import 'server-only';
import type { Invoice } from '@/lib/types';
const eur = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' });
const date = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' });
// Templates render values as given: there are no formatting helpers in Handlebars here, by
// design. Currency, rounding, dates and locale are decisions your code owns.
export function toInvoiceData(invoice: Invoice) {
return {
invoiceNumber: invoice.number,
issueDate: date.format(invoice.issuedAt),
dueDate: date.format(invoice.dueAt),
company: {
name: 'Northwind Studio',
addressLine1: '128 Maple Avenue, Suite 5',
addressLine2: 'Portland, OR 97204',
email: 'billing@northwind.studio',
},
billTo: {
name: invoice.customer.name,
addressLine1: invoice.customer.addressLine1,
addressLine2: invoice.customer.addressLine2,
},
items: invoice.lines.map((line) => ({
description: line.description,
quantity: String(line.quantity),
rate: eur.format(line.unitPriceCents / 100),
amount: eur.format((line.quantity * line.unitPriceCents) / 100),
})),
subtotal: eur.format(invoice.subtotalCents / 100),
taxLabel: `VAT (${invoice.taxRatePercent}%)`,
taxAmount: eur.format(invoice.taxCents / 100),
total: eur.format(invoice.totalCents / 100),
payment: { bankName: 'First Cascade Bank', accountNumber: '0041 2298 7745', swift: 'FCSBUS6P' },
notes: invoice.notes ?? undefined,
};
}
Two things this buys you. Every number the customer sees was rounded once, by code you can test.
And because the template's variables are a stated contract, a rename shows up as an
invalid_data error naming the missing field, not as a blank
space on a PDF a customer already has. The data-contract view in the
editor lists what a template requires.
4. The route handler
// app/api/invoices/[id]/pdf/route.ts
import { pdfglyph } from '@/lib/pdfglyph';
import { toInvoiceData } from '@/lib/invoice-pdf-data';
import { getInvoiceForCurrentUser } from '@/lib/invoices';
// A render is bounded server-side, and the SDK may retry once — so give the route more
// headroom than the render budget rather than the platform default.
export const maxDuration = 60;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Authorize FIRST: this route returns a customer's financial document.
const invoice = await getInvoiceForCurrentUser(id);
if (!invoice) return new Response('Not found', { status: 404 });
const { pdf } = await pdfglyph.generate({
templateId: process.env.PDFGLYPH_TEMPLATE_ID!,
data: toInvoiceData(invoice),
});
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
// `inline` opens in the browser's viewer; `attachment` forces a download.
'Content-Disposition': `inline; filename="invoice-${invoice.number}.pdf"`,
'Content-Length': String(pdf.byteLength),
// A finished invoice never changes: let the browser and the CDN keep it.
'Cache-Control': 'private, max-age=3600, immutable',
},
});
}
pdf is a Uint8Array, which is a valid Response body: no Buffer, no stream plumbing. The
same code runs on the Node runtime, the Edge runtime, or any other host you deploy to.
params is a Promise in Next.js 15+. On 14 and earlier it's a plain object, so drop the
await and type it as { params: { id: string } }.
5. Handle the errors you will actually get
A generation can fail for reasons your caller must be told apart: bad data is your bug, quota is an account problem, rate limiting is a "try again". Each is a class you catch:
import {
InvalidDataError,
LimitExceededError,
RateLimitedError,
PdfGlyphError,
} from 'pdfglyph';
try {
const { pdf } = await pdfglyph.generate({ templateId, data: toInvoiceData(invoice) });
return new Response(pdf, { headers: pdfHeaders(invoice) });
} catch (err) {
if (err instanceof InvalidDataError) {
// 422 — the payload doesn't satisfy the template. The message names each field, so log it:
// this is a mapping bug in toInvoiceData, and it will fail for every invoice, not just one.
console.error({ invoiceId: invoice.id, requestId: err.requestId }, err.message);
return new Response('Invoice PDF unavailable', { status: 500 });
}
if (err instanceof LimitExceededError) {
// 429 and NOT retryable: the monthly quota is spent. Alert someone; a retry cannot help.
await notifyOps('PDFglyph quota reached');
return new Response('Invoice PDF temporarily unavailable', { status: 503 });
}
if (err instanceof RateLimitedError) {
// The SDK already retried with backoff. Pass the server's own wait hint to the caller.
return new Response('Busy', {
status: 503,
headers: { 'Retry-After': String(err.retryAfter ?? 30) },
});
}
if (err instanceof PdfGlyphError) {
console.error({ code: err.code, requestId: err.requestId }, err.message);
return new Response('Invoice PDF unavailable', { status: 500 });
}
throw err;
}
Log requestId on every failure. It's the correlation id support filters by, and it turns "a
PDF failed sometime yesterday" into one request. The full list is in
Errors. A failed generation never consumes quota, so retrying a
transient failure costs nothing.
6. Make repeats free: idempotency
A user double-clicks Download. A queue retries a job. A payment webhook is redelivered. Without a
key, each of those is a fresh generation and a fresh unit of quota. With one, the repeat comes back
as the same generation, billed once, flagged replayed. Derive the key from the business
event. A random UUID would give every retry a fresh key and replay nothing:
const { pdf, replayed } = await pdfglyph.generate({
templateId: process.env.PDFGLYPH_TEMPLATE_ID!,
data: toInvoiceData(invoice),
// Same invoice, same revision -> same key -> replay. Bump the revision when the invoice is
// edited, so a corrected invoice renders again instead of replaying the stale one.
idempotencyKey: `invoice-pdf:${invoice.id}:${invoice.revision}`,
});
Keys live 24 hours, and reusing one with different data is rejected as a conflict rather than
silently serving the old document. Because PDFs are never stored, a replay re-renders the bytes
from the same template version instead of returning a saved file, so the document is equivalent
rather than byte-identical (its internal /CreationDate differs). See
idempotency.
7. Emailing it instead of streaming it
The other half of most invoicing features. Same call, different destination, this time in a Server Action:
// app/invoices/actions.ts
'use server';
import { pdfglyph } from '@/lib/pdfglyph';
import { toInvoiceData } from '@/lib/invoice-pdf-data';
export async function emailInvoice(invoiceId: string) {
const invoice = await getInvoiceForCurrentUser(invoiceId);
if (!invoice) throw new Error('Not found');
const { pdf } = await pdfglyph.generate({
templateId: process.env.PDFGLYPH_TEMPLATE_ID!,
data: toInvoiceData(invoice),
idempotencyKey: `invoice-email:${invoice.id}:${invoice.revision}`,
});
await mailer.send({
to: invoice.customer.email,
subject: `Invoice ${invoice.number}`,
attachments: [
// Most mail APIs want base64 or a Buffer; the bytes convert either way.
{ filename: `invoice-${invoice.number}.pdf`, content: Buffer.from(pdf) },
],
});
}
Generate the PDF before you send, and let the send fail loudly. Emailing a customer a link to a document that doesn't exist yet is worse than a failed job.
Deployment notes for Vercel
- Response bodies are capped at 4.5 MB. A one-page invoice is far below it. A 60-page statement with images may not be. Above the cap, write the bytes to object storage and redirect to a signed URL instead of returning them.
- Nothing needs the Node runtime. The SDK is
fetch-based, so the Edge runtime works. If anything pins you to Node, it's usually your database client. maxDurationbeats the default. Set it on the route, as above, so a slow render isn't cut off mid-flight by the platform.- No
outputFileTracingIncludes, no binaries, no layers. The whole integration is one dependency and afetch.
Verify it end to end
curl -i http://localhost:3000/api/invoices/inv_123/pdf --output invoice.pdfRunning this guide's code against the live API returns:
HTTP/1.1 200 OK
content-type: application/pdf
content-disposition: inline; filename="invoice-INV-2026-0042.pdf"
content-length: 67084
cache-control: private, max-age=3600, immutable
invoice.pdf: PDF document, version 1.4, 1 pagesYou want 200, Content-Type: application/pdf, and a file that opens. Then check the
History view in the dashboard: the generation is there with its template version, duration,
and status. It's the same record support would look at. If the numbers in the PDF disagree with
your database, the bug is in toInvoiceData, and a unit test on that function is the fix.
Next steps
- TypeScript SDK: retries, cancellation, batching.
- Build an invoice: write your own template instead of cloning one.
- Going to production: key rotation, monitoring, plan sizing.
- Generate PDFs from Laravel: the same integration in PHP.