Get started

PDF generation in Next.js: a complete guide for App Router

Generate PDFs in Next.js 14+ App Router using server actions, API routes, and Playwright. Includes React-to-PDF, dynamic templates, and PDF4.dev API examples.

benoitdedMarch 19, 202611 min read

PDF generation in Next.js is a common requirement: invoices, reports, certificates, order confirmations. The correct approach depends on your rendering constraints, hosting environment, and how complex your PDF designs need to be.

This guide covers every method, when to use each, and a complete implementation using Next.js 14+ App Router with PDF4.dev.

What are the options for generating PDFs in Next.js?

MethodWorks on VercelCSS supportSetup effortBest for
PDF API (PDF4.dev)YesFull HTML/CSSLowProduction apps, complex layouts
Playwright (self-hosted)NoFull HTML/CSSHighSelf-hosted, full control
react-pdf (@react-pdf/renderer)YesJSX-only stylingMediumSimple documents without CSS
jsPDFYes (client)LimitedLowSimple client-side generation
window.print()Yes (client)Full browser CSSMinimalQuick print without download

The most practical choice for production Next.js apps deployed on Vercel or Railway is a PDF API. It handles the browser binary, keeps your function bundle small, and works the same on any host.

A PDF API is an HTTP endpoint that accepts HTML and data, renders it server-side with a headless browser, and returns a PDF buffer. PDF4.dev uses Chromium under the hood — the same engine as Playwright — so you get identical rendering quality without managing the browser.

Why route handlers, not server components

PDF generation must happen inside a route handler (app/api/*/route.ts), not a server component. Server components render React trees and return HTML to the browser. A route handler returns arbitrary binary responses, which is what PDF downloads require.

Server actions are an alternative when you want to generate a PDF and immediately store it (e.g., save to S3), but for streaming a download to the user, a route handler is simpler.

Complete Next.js PDF generation setup

1. Create a template in PDF4.dev

Log into pdf4.dev and create a new template. Give it the slug invoice-template. Use Handlebars variables for dynamic fields:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Inter, sans-serif; padding: 40px; color: #111; }
    .header { display: flex; justify-content: space-between; margin-bottom: 40px; }
    .company { font-size: 24px; font-weight: 700; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { text-align: left; padding: 10px; border-bottom: 2px solid #eee; font-size: 12px; color: #666; }
    td { padding: 10px; border-bottom: 1px solid #f0f0f0; }
    .total { text-align: right; margin-top: 24px; font-size: 18px; font-weight: 700; }
  </style>
</head>
<body>
  <div class="header">
    <div class="company">{{company_name}}</div>
    <div>
      <strong>Invoice #{{invoice_number}}</strong><br>
      {{invoice_date}}
    </div>
  </div>
 
  <p>Bill to: <strong>{{client_name}}</strong></p>
 
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Unit price</th>
        <th>Total</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{description}}</td>
        <td>{{qty}}</td>
        <td>{{unit_price}}</td>
        <td>{{total}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>
 
  <div class="total">Total: {{grand_total}}</div>
</body>
</html>

2. Create the route handler

// app/api/generate-pdf/route.ts
import { NextRequest } from "next/server";
 
const PDF4_API_KEY = process.env.PDF4_API_KEY!;
 
export async function POST(request: NextRequest) {
  const data = await request.json();
 
  const response = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: "invoice-template",
      data,
    }),
  });
 
  if (!response.ok) {
    const error = await response.json();
    return Response.json({ error: error.error }, { status: response.status });
  }
 
  const pdfBuffer = await response.arrayBuffer();
 
  return new Response(pdfBuffer, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="invoice-${data.invoice_number}.pdf"`,
    },
  });
}

3. Add the API key to environment variables

# .env.local
PDF4_API_KEY=p4_live_your_key_here

Get your API key from the PDF4.dev dashboard under Settings.

4. Trigger generation from a client component

// components/InvoiceDownloadButton.tsx
"use client";
 
interface InvoiceData {
  company_name: string;
  invoice_number: string;
  invoice_date: string;
  client_name: string;
  items: { description: string; qty: number; unit_price: string; total: string }[];
  grand_total: string;
}
 
export function InvoiceDownloadButton({ data }: { data: InvoiceData }) {
  async function handleDownload() {
    const response = await fetch("/api/generate-pdf", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
 
    if (!response.ok) {
      console.error("PDF generation failed");
      return;
    }
 
    const blob = await response.blob();
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `invoice-${data.invoice_number}.pdf`;
    a.click();
    URL.revokeObjectURL(url);
  }
 
  return (
    <button onClick={handleDownload} type="button">
      Download invoice PDF
    </button>
  );
}

5. Use it in a server component

// app/invoices/[id]/page.tsx
import { InvoiceDownloadButton } from "@/components/InvoiceDownloadButton";
import { getInvoice } from "@/lib/db";
 
export default async function InvoicePage({ params }: { params: { id: string } }) {
  const invoice = await getInvoice(params.id);
 
  return (
    <div>
      <h1>Invoice #{invoice.number}</h1>
      <InvoiceDownloadButton data={invoice} />
    </div>
  );
}

This pattern keeps the PDF generation fully server-side. The client component only triggers the fetch and handles the download; no PDF library ships to the browser bundle.

Option 2: Playwright inside a route handler

Playwright generates PDFs by launching a headless Chromium browser inside your Next.js process. This gives you full control but requires a Node.js host (not Vercel), adds ~300 MB to your image, and introduces browser lifecycle management.

Use this when you need to self-host everything or cannot use an external API.

npm install playwright
npx playwright install chromium
// app/api/generate-pdf-playwright/route.ts
import { chromium, Browser } from "playwright";
 
// Reuse the browser instance across requests (singleton pattern)
let browser: Browser | null = null;
 
async function getBrowser(): Promise<Browser> {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({ args: ["--no-sandbox"] });
  }
  return browser;
}
 
export async function POST(request: Request) {
  const { html } = await request.json();
 
  const b = await getBrowser();
  const page = await b.newPage();
 
  await page.setContent(html, { waitUntil: "networkidle" });
 
  const pdf = await page.pdf({
    format: "A4",
    margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
    printBackground: true,
  });
 
  await page.close();
 
  return new Response(pdf, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": 'attachment; filename="document.pdf"',
    },
  });
}

The singleton getBrowser() pattern is necessary. Launching a new Chromium process for every request adds 1-3 seconds of cold start and will exhaust memory under concurrent load. With a warm singleton, subsequent renders complete in 200-300ms.

Playwright on Docker

If you deploy via Docker (Railway, Fly.io, VPS), include the Chromium system dependencies:

FROM node:22-slim
 
# Chromium dependencies
RUN apt-get update && apt-get install -y \
  libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
  libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
  libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \
  && rm -rf /var/lib/apt/lists/*
 
WORKDIR /app
COPY . .
RUN npm ci && npx playwright install chromium
CMD ["node", "server.js"]

Why Playwright does not work on Vercel

Vercel enforces a 250 MB deployment bundle limit. Playwright's Chromium binary is approximately 300 MB. Additionally, Vercel's serverless functions do not support spawning subprocess, which Playwright requires.

If you are on Vercel, use a PDF API (Option 1) or run Playwright on a dedicated sidecar service.

Option 3: react-pdf (@react-pdf/renderer)

react-pdf is a React library that generates PDFs from a JSX component tree using its own layout engine. It does not use a browser, so it works anywhere JavaScript runs.

npm install @react-pdf/renderer
// components/InvoicePDF.tsx
"use client";
 
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
import dynamic from "next/dynamic";
 
const PDFDownloadLink = dynamic(
  () => import("@react-pdf/renderer").then((m) => m.PDFDownloadLink),
  { ssr: false }
);
 
const styles = StyleSheet.create({
  page: { padding: 40, fontSize: 12 },
  header: { flexDirection: "row", justifyContent: "space-between", marginBottom: 30 },
  title: { fontSize: 20, fontWeight: "bold" },
  row: { flexDirection: "row", borderBottom: "1px solid #eee", padding: "8px 0" },
});
 
function InvoiceDocument({ data }: { data: InvoiceData }) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <View style={styles.header}>
          <Text style={styles.title}>{data.company_name}</Text>
          <Text>Invoice #{data.invoice_number}</Text>
        </View>
        <Text>Bill to: {data.client_name}</Text>
        {data.items.map((item, i) => (
          <View key={i} style={styles.row}>
            <Text>{item.description} — {item.total}</Text>
          </View>
        ))}
        <Text>Total: {data.grand_total}</Text>
      </Page>
    </Document>
  );
}
 
export function InvoicePDFButton({ data }: { data: InvoiceData }) {
  return (
    <PDFDownloadLink document={<InvoiceDocument data={data} />} fileName="invoice.pdf">
      Download PDF
    </PDFDownloadLink>
  );
}

Limitations of react-pdf:

  • No HTML or CSS: layouts use a Flexbox-like API specific to react-pdf. Existing HTML designs cannot be reused.
  • Font rendering: custom fonts require manual registration with Font.register().
  • No Tailwind, no CSS variables, no pseudo-classes.
  • The PDFDownloadLink and PDFViewer components require ssr: false because they use browser APIs (URL.createObjectURL, canvas).

react-pdf works well for simple, code-defined documents like plain invoices or receipts where you control every element. For complex HTML/CSS designs (charts, rich tables, branded layouts), HTML-to-PDF gives more accurate results.

Option 4: jsPDF (client-side, simple documents)

jsPDF generates PDFs entirely in the browser using a JavaScript PDF writer. It does not use a browser engine — it builds the PDF structure programmatically.

"use client";
 
import jsPDF from "jspdf";
 
export function SimpleDownloadButton() {
  function generate() {
    const doc = new jsPDF();
    doc.setFontSize(20);
    doc.text("Invoice #001", 20, 20);
    doc.setFontSize(12);
    doc.text("Total: $1,500.00", 20, 40);
    doc.save("invoice.pdf");
  }
 
  return <button onClick={generate}>Download PDF</button>;
}

jsPDF is appropriate for basic, text-only documents where you control exact coordinates. It has no HTML renderer, so complex layouts require manual positioning of every element. For real-world document generation, HTML-to-PDF or react-pdf is more practical.

Comparison: which method to choose?

RequirementBest approach
Complex HTML/CSS design (tables, charts, images)PDF API (PDF4.dev) or Playwright
Deploying to VercelPDF API (PDF4.dev)
Self-hosted, full controlPlaywright
No external dependencies allowedreact-pdf or jsPDF
Reuse existing HTML/email templatesPDF API or Playwright
Client-side generation (no server)react-pdf or jsPDF
High concurrency (100+ simultaneous requests)PDF API (managed concurrency)
Simple text documentsreact-pdf or jsPDF

Generating PDFs with Next.js server actions

Server actions can call a PDF API and return the result, but downloading binary data from a server action requires care. The standard pattern is to store the PDF in temporary storage (S3, R2) and return a signed URL, rather than piping binary directly through the action.

// app/actions/generatePdf.ts
"use server";
 
export async function generateAndStorePdf(data: InvoiceData) {
  const response = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: "invoice-template",
      data,
    }),
  });
 
  const pdfBuffer = await response.arrayBuffer();
 
  // Store in S3/R2, return a signed URL
  const url = await uploadToStorage(pdfBuffer, `invoices/${data.invoice_number}.pdf`);
  return url;
}

For direct downloads (generate and immediately send to browser), a route handler (app/api/*/route.ts) is simpler than a server action because it can return a streaming binary response directly.

Handling environment variables and API keys

Never expose your PDF API key to the client. In Next.js:

  • Store the key in .env.local (local dev) or your hosting provider's environment variables (Vercel, Railway)
  • Access it only in route handlers, server components, or server actions via process.env.PDF4_API_KEY
  • Never pass it to a "use client" component or NEXT_PUBLIC_* variable
# .env.local — never commit this file
PDF4_API_KEY=p4_live_your_key_here

Performance considerations for production

Cold start time. A PDF API like PDF4.dev maintains a warm browser pool server-side. Your Next.js app makes an HTTP request (50-150ms network) and gets a PDF back in 200-400ms total. No cold start for the browser on your end.

Caching. If you generate the same PDF multiple times with the same data (e.g., re-downloading a past invoice), cache the PDF in your database or object storage instead of re-rendering. One render per unique document is sufficient.

Concurrent requests. Under load, self-hosted Playwright requires a queue or pool to avoid spinning up dozens of browser instances simultaneously. A managed PDF API handles concurrency transparently.

Bundle size. PDF libraries like react-pdf add approximately 400 KB to your client bundle. jsPDF adds about 250 KB. PDF4.dev adds zero client-side bytes: the work happens on the server. Use dynamic(() => import(...), { ssr: false }) for any client-side PDF library to avoid SSR issues and defer loading until needed.

Complete Next.js App Router example

Here is the full file structure for a Next.js project with PDF invoice generation using PDF4.dev:

app/
├── api/
│   └── generate-pdf/
│       └── route.ts          # POST handler → calls PDF4.dev API
├── invoices/
│   └── [id]/
│       └── page.tsx          # Server component fetches invoice data
components/
├── InvoiceDownloadButton.tsx  # "use client" — triggers fetch + download
.env.local                     # PDF4_API_KEY=p4_live_...

The route handler keeps all API credentials server-side. The server component fetches data from your database. The client component is purely UI: a button that triggers a fetch. Each layer does one thing.

Try the HTML-to-PDF converter in your browser to test your template before writing any code: PDF4.dev HTML to PDF tool.

For more on building PDF templates with Handlebars, see how to generate PDF invoices programmatically. For a comparison of all PDF generation methods and benchmark data, see the HTML to PDF benchmark 2026.

Free tools mentioned:

Html To PdfTry it freeMerge PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.