← Back to blog

Generate PDFs in Next.js App Router with one line

Add a PDF endpoint to your Next.js app in 30 seconds. Route handlers, server actions, and custom templates.

You need a PDF endpoint. A customer clicks "Download Invoice" and gets a PDF. In Next.js App Router, this should be trivial. It isn't.

You have to figure out how to run a PDF library in a server environment, set the right response headers, handle Content-Disposition for downloads vs browser preview, convert the output to the right format for the Response constructor. It's not hard, but it's annoying, and you get it slightly wrong the first time every time.

Here's what it looks like now:

// app/api/invoice/[id]/route.ts
import { pdfHandler } from '@formepdf/next';

export const GET = pdfHandler('invoice', async (req, { params }) => {
  const invoice = await db.invoices.findById(params.id);

  return {
    company: 'Acme Corp',
    customer: invoice.customerName,
    invoiceNumber: invoice.number,
    items: invoice.lineItems,
    total: invoice.total,
  };
});

Hit GET /api/invoice/123. Get a PDF. The headers are set, the content type is correct, the filename is invoice.pdf. Done.

Three levels of abstraction

The package gives you three functions depending on how much control you want.

pdfHandler creates a complete route handler. You give it a template name and a function that returns data. It handles the render, the Response, the headers, and error handling. This is what you want 90% of the time.

// Force download instead of browser preview
export const GET = pdfHandler('invoice', fetchInvoiceData, {
  filename: 'invoice-001.pdf',
  download: true,
});

pdfResponse returns a Response object. Use it inside an existing route handler when you need conditional logic:

export async function GET(req: NextRequest) {
  const type = new URL(req.url).searchParams.get('type');

  if (type === 'invoice') return pdfResponse('invoice', invoiceData);
  if (type === 'receipt') return pdfResponse('receipt', receiptData);

  return Response.json({ error: 'Unknown type' }, { status: 400 });
}

renderPdf returns raw Uint8Array bytes. Use it in Server Actions or anywhere you need the bytes without a Response wrapper:

'use server';

import { renderPdf } from '@formepdf/next';
import { put } from '@vercel/blob';

export async function generateInvoice(invoiceId: string) {
  const invoice = await db.invoices.findById(invoiceId);
  const pdfBytes = await renderPdf('invoice', {
    company: 'Acme Corp',
    customer: invoice.customerName,
    items: invoice.lineItems,
    total: invoice.total,
  });

  const { url } = await put(`invoices/${invoice.number}.pdf`, pdfBytes, {
    access: 'public',
    contentType: 'application/pdf',
  });

  return url;
}

Render the PDF, upload to Vercel Blob, return the URL. Three lines of logic.

Custom templates

The built-in templates cover common cases: invoice, receipt, report, letter, shipping label. But you can render any Forme JSX template:

import { MyQuarterlyReport } from '@/templates/quarterly-report';

export const GET = pdfHandler(async (req) => {
  const data = await fetchReportData();
  return () => MyQuarterlyReport(data);
}, { filename: 'Q1-report.pdf' });

Pass a function that returns a render function. The package calls it, renders the result to PDF, and returns the Response.

No Puppeteer, no Chrome

This runs in a standard Vercel function. The PDF engine is a 3MB WASM binary, not a 200MB headless browser. There's no cold start penalty from Chrome, no separate server to maintain, no 50MB bundle limit to fight.

A 4-page invoice renders in about 28ms. That's fast enough to generate on every request without caching.

Install

npm install @formepdf/next @formepdf/react @formepdf/core

Then create your first route handler and hit the endpoint. That's it.

GitHub | Docs