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 pdfglyph

Zero 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_8x2k

Then 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.
  • maxDuration beats 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 a fetch.

Verify it end to end

curl -i http://localhost:3000/api/invoices/inv_123/pdf --output invoice.pdf

Running 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 pages

You 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