Generating branded PDFs on the fly in Next.js — and the four things that break

A client wants a "download catalog" button. The catalog changes every week. Nobody wants to re-export a PDF by hand every week, which is the entire reason the button exists.

So you generate it on request: the user clicks, your server builds the PDF from whatever is in the CMS right now, and streams it back. No files to store, no exports to remember, nothing to go stale.

The concept takes ten minutes. Getting it to survive the Next.js App Router and a serverless deploy takes considerably longer, and the reasons are not in the pdfkit docs. Here is the whole thing, including the four failures you will hit in order.

The route handler

A PDF is just a response with the right content type. In the App Router that means a route handler:

// app/api/catalog/[slug]/route.ts
import { NextResponse } from "next/server";
import { generateCatalogPdf } from "@/lib/pdf/catalog";

export const runtime = "nodejs";

export async function GET(req: Request, { params }: Params) {
  const { slug } = await params;
  const brand = await getBrandCatalog(slug);

  if (!brand?.name) {
    return NextResponse.json({ error: "Brand not found" }, { status: 404 });
  }

  const pdf = await generateCatalogPdf({
    brand: brand.name,
    subtitle: brand.categoryName,
    products: brand.productList,
  });

  return new NextResponse(new Uint8Array(pdf), {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `inline; filename="catalog-${slug}.pdf"`,
      "Cache-Control": "public, max-age=3600",
    },
  });
}

Four lines in there are load-bearing, and three of them are the fixes to bugs you have not hit yet. Let's go through them.

Break #1: the Edge runtime cannot do this

export const runtime = "nodejs";

Leave this out and it may work locally and fail on deploy, which is the worst kind of failure.

PDF libraries need Node: Buffers, streams, and in pdfkit's case the filesystem. The Edge runtime has none of those. That single line is not boilerplate — it is the difference between a working route and a deploy-time explosion.

Break #2: Helvetica.afm — ENOENT

This is the one that costs people an afternoon:

Error: ENOENT: no such file or directory, open '.../data/Helvetica.afm'

pdfkit ships font metrics for the standard PDF fonts as .afm files and reads them from disk at runtime. Next.js bundles your server code, the bundler does not know those data files are needed, they never make it into the deployment, and the first request that tries to draw text dies.

The fix is one line of config:

// next.config.mjs
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ["pdfkit"],
  },
};

This tells Next to leave pdfkit alone — don't bundle it, require it from node_modules at runtime — so its data files come along. (On Next 15 the same option lives at serverExternalPackages, no longer under experimental.)

Worth knowing even if you never use pdfkit: any library that reads its own files at runtime needs this treatment.

Break #3: the response body type

return new NextResponse(new Uint8Array(pdf), { ... });

generateCatalogPdf returns a Node Buffer. A Buffer is a Uint8Array — it extends it — so this looks like a pointless conversion, and TypeScript will still reject the Buffer, because Next 14's BodyInit typing does not accept it.

The conversion is zero-copy: same memory, different view. It costs nothing at runtime and it makes the types agree. Do it and move on; this is not the hill.

Break #4: buffer, don't stream

pdfkit is stream-shaped: you write to a document, it emits data events, and it ends. The temptation is to pipe that straight into the response. Resist it — collect the chunks and resolve a single Buffer:

export function generateCatalogPdf(opts: CatalogOptions): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ size: "A4", margin: 50 });
    const chunks: Buffer[] = [];

    doc.on("data", (chunk: Buffer) => chunks.push(chunk));
    doc.on("end", () => resolve(Buffer.concat(chunks)));
    doc.on("error", reject);

    // ... draw the document ...

    doc.end();
  });
}

Two reasons. First, error handling: if generation fails halfway through a streamed response you have already sent a 200 and some bytes, and the user gets a corrupt download instead of an error page. Buffering means a failure is still a clean 500. Second, catalogs are small — tens of kilobytes — so there is no memory argument on the other side. Stream when the payload is big enough to justify losing your error handling. A product catalog is not.

Making it look like it belongs to the brand

A generated PDF that looks like a generated PDF defeats the purpose. The trick is to stop treating the document as a separate design and derive it from the same tokens as the site:

import { theme } from "@/lib/theme";

const ACCENT  = opts.palette?.accent  ?? theme.brand.DEFAULT;
const HEADING = opts.palette?.heading ?? theme.ink.DEFAULT;

Now the masthead band in the PDF is literally the same colour as the site hero, and rebranding for a new client is one token change in one file rather than a hunt through drawing code.

The header band itself is four calls:

// Brand-coloured masthead, mirroring the website hero.
doc.rect(0, 0, PAGE_W, BAND_H).fill(ACCENT);

if (company.logoPath && fs.existsSync(company.logoPath)) {
  doc.image(company.logoPath, MARGIN, 38, { width: 110 });
} else {
  doc.fillColor(WHITE).fontSize(22).font("Helvetica-Bold")
    .text(company.name, MARGIN, 40, { width: 300 });
}

Note the fs.existsSync guard. A logo is optional data from a CMS, and a missing file should degrade to a wordmark, not throw inside a route handler.

Pagination, which pdfkit will not do for you

pdfkit has no concept of a table and no concept of a row that doesn't fit. You track the Y position and decide:

const drawTableHeader = (y: number): number => {
  doc.rect(MARGIN, y, CONTENT_R - MARGIN, 22).fill(TINT);
  doc.fillColor(ACCENT).fontSize(9).font("Helvetica-Bold");
  doc.text("PRODUCT", MARGIN + 12, y + 7, { width: 308 });
  doc.text("FORMAT", 370, y + 7, { width: 163 });
  return y + 22 + 6;
};

let tableY = drawTableHeader(headY + 28);

for (const product of opts.products) {
  if (tableY > TABLE_BOTTOM) {
    doc.addPage();
    tableY = drawTableHeader(60);   // repeat the header on the new page
  }
  doc.fillColor(HEADING).fontSize(10).font("Helvetica")
    .text(product.name, MARGIN + 12, tableY, { width: 308 });
  tableY += ROW_H;
}

Making drawTableHeader return the next Y is what keeps this readable. And redrawing the header after addPage() is the detail that separates a document that looks designed from one that looks dumped: page four of a catalog with unlabelled columns reads as broken.

TABLE_BOTTOM is a constant (770 on A4 with 50pt margins) rather than a computed value, on purpose. Page geometry in a fixed-size document is a layout decision, not a runtime one.

Two things about the response headers

Content-Disposition: inline vs attachment. inline opens the PDF in the browser's viewer; attachment forces a download. For a catalog, inline wins — people want to look before they commit to a file in their Downloads folder. Either way, set filename: without it the browser names the file after the URL segment, and your user ends up with a1b2c3.pdf.

Caching a dynamic file. public, max-age=3600 looks contradictory on a generated document, but a catalog changes when someone edits the CMS, not per request. An hour of CDN caching turns the common case into a static file serve. If you need instant invalidation, drop the max-age and use a revalidation tag instead — but measure first, because generating this document costs milliseconds.

The escape hatch worth building

Whatever you generate, someone will eventually want to upload their own. Give them the door:

// An uploaded PDF (from the CMS) takes priority.
if (brand.catalogPdfUrl) {
  return NextResponse.redirect(brand.catalogPdfUrl, 302);
}

// Otherwise, generate one from the product list.

Six lines, and the feature stops being all-or-nothing. The client with a designer-made catalog uses theirs; everyone else gets one generated. The same URL serves both, so nothing downstream needs to know which happened.

What this actually buys

The point of generating on request is not elegance. It is that the catalog is never out of date, because there is no export step that someone can forget. Edit a product in the CMS, and the next person who clicks the button gets a PDF with it.

That is an hour a week that stops existing.


This is how the PDF works in the B2B catalog template — a Next.js 14 + Sanity starter for companies that need a product catalog on the web and as a download. Live demo, no signup.