Generate PDFs in Next.js App Router with Api2Pdf: Route Handlers, Server Actions and Vercel

Summary. Most Next.js apps eventually need a "Download PDF" button: invoices, receipts, reports, certificates, contracts. Running Puppeteer and a Chromium binary inside a Vercel function is possible, but it fights bundle size limits, cold starts and memory, and it turns a feature into an infrastructure project. This guide keeps the browser out of your deployment. Your App Router code builds the HTML (or points at a page of your own app), sends it to the Api2Pdf Headless Chrome endpoint, and streams the PDF back to the user. You will build a server-only API client with timeouts and retries, a Route Handler that returns the PDF bytes, a Server Action that returns a hosted link, and a variant that prints an existing React page through a signed, short-lived URL. Everything here was type-checked and built against Next.js 16 with React 19, and it works the same way on Next.js 15.

The architecture in one paragraph

The API key lives only on the server. A Route Handler (app/.../route.ts) or a Server Action loads your data, renders an HTML string, and calls POST https://v2.api2pdf.com/chrome/pdf/html with the raw API key in the Authorization header. Add ?outputBinary=true and the response body is the PDF itself, which the Route Handler passes straight to the browser. Leave it off and you get JSON with a FileUrl that Api2Pdf hosts for 24 hours, which suits Server Actions, emails and background jobs. Nothing in this flow runs Chrome on your servers, so the function stays small and fast to cold start.

Step 1: install and configure

You need an Api2Pdf API key from portal.api2pdf.com. Put it in .env.local (and in your Vercel project settings) without the NEXT_PUBLIC_ prefix, so Next.js never ships it to the browser:

# .env.local
API2PDF_API_KEY=your-api-key
# optional: https://v2-xl.api2pdf.com for very large documents
API2PDF_BASE_URL=https://v2.api2pdf.com
# only needed for the "print an existing page" variant in step 5
PDF_TOKEN_SECRET=a-long-random-string
PUBLIC_APP_URL=https://your-app.example.com

Install server-only so that importing the client from a Client Component fails the build instead of leaking the key:

npm install server-only

Step 2: a small server-only client

You can use the official api2pdf npm package (more on that below), but the REST API is small enough that a typed wrapper around fetch gives you more control in a serverless function: a hard timeout with AbortSignal.timeout, retries only where they make sense, and no dependency on Node-only modules. The request body takes html (or url), plus the optional fileName, inline and options (the Headless Chrome options such as margins, page size, headers and footers). outputBinary is a query string parameter.

import 'server-only';

const BASE_URL = process.env.API2PDF_BASE_URL ?? 'https://v2.api2pdf.com';

export type ChromePdfOptions = {
  landscape?: boolean;
  printBackground?: boolean;
  width?: string;
  height?: string;
  marginTop?: string;
  marginBottom?: string;
  marginLeft?: string;
  marginRight?: string;
  preferCSSPageSize?: boolean;
  displayHeaderFooter?: boolean;
  headerTemplate?: string;
  footerTemplate?: string;
  delay?: number;
  puppeteerWaitForMethod?: string;
  puppeteerWaitForValue?: string;
};

export type Api2PdfResponse = {
  Success: boolean;
  Error: string | null;
  FileUrl: string | null;
  MbOut: number;
  Cost: number;
  Seconds: number;
  ResponseId: string;
};

export class Api2PdfError extends Error {
  constructor(message: string, public status: number, public responseId?: string) {
    super(message);
    this.name = 'Api2PdfError';
  }
}

type Source = { html: string } | { url: string };
type CallOptions = {
  fileName?: string;
  options?: ChromePdfOptions;
  timeoutMs?: number;
  retries?: number;
};

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function chromePdf(source: Source, binary: boolean, opts: CallOptions = {}) {
  const { fileName, options, timeoutMs = 45_000, retries = 2 } = opts;
  const path = 'html' in source ? '/chrome/pdf/html' : '/chrome/pdf/url';
  const body = JSON.stringify({ ...source, fileName, inline: true, options });

  for (let attempt = 0; ; attempt++) {
    let res: Response;
    try {
      res = await fetch(`${BASE_URL}${path}?outputBinary=${binary}`, {
        method: 'POST',
        headers: {
          Authorization: process.env.API2PDF_API_KEY!, // the raw key, no "Bearer"
          'Content-Type': 'application/json',
        },
        body,
        signal: AbortSignal.timeout(timeoutMs),
        cache: 'no-store',
      });
    } catch (err) {
      // network error or timeout: retry, then give up
      if (attempt < retries) { await sleep(500 * 2 ** attempt); continue; }
      throw err;
    }

    const isJson = (res.headers.get('content-type') ?? '').includes('json');
    if (res.ok && binary && !isJson) return res.arrayBuffer();

    const data = (isJson ? await res.json() : null) as Api2PdfResponse | null;
    if (res.ok && data?.Success) return data;

    // 429 and 5xx are worth another try; 400/401/402 are not
    const retryable = res.status === 429 || res.status >= 500;
    if (retryable && attempt < retries) { await sleep(500 * 2 ** attempt); continue; }
    throw new Api2PdfError(data?.Error ?? `Api2Pdf returned HTTP ${res.status}`, res.status, data?.ResponseId);
  }
}

/** Render HTML and get the PDF bytes back in the response. */
export function htmlToPdfBytes(html: string, opts?: CallOptions) {
  return chromePdf({ html }, true, opts) as Promise<ArrayBuffer>;
}

/** Render HTML and get JSON back with a FileUrl hosted for 24 hours. */
export function htmlToPdfFile(html: string, opts?: CallOptions) {
  return chromePdf({ html }, false, opts) as Promise<Api2PdfResponse>;
}

/** Render a public URL (a print page of your own app, say) and get the PDF bytes. */
export function urlToPdfBytes(url: string, opts?: CallOptions) {
  return chromePdf({ url }, true, opts) as Promise<ArrayBuffer>;
}

A few details that matter in production:

  • The Authorization header is the raw key. No Bearer prefix. A wrong key returns HTTP 401 with {"Success": false, "Error": "Invalid API key", ...}, even when you asked for binary output, which is why the client checks the Content-Type before treating the body as a PDF.
  • A request with no key at all returns 402 with an x402 payment offer instead of a plain error. If you see a 402 in your logs, the environment variable is missing in that environment.
  • Retry only what is retryable. Network errors, timeouts, 429 and 5xx get two more attempts with backoff; 400 and 401 fail immediately, because sending the same bad request again will not fix it.
  • The JSON response carries Success, Error, FileUrl, MbOut, Cost, Seconds and ResponseId. Log ResponseId on failures; support can trace a request with it.

Step 3: render your document as HTML

Any templating approach works, because Api2Pdf only sees the final HTML. A plain template literal is the most portable inside App Router server code. The one rule: escape every value that comes from a user or a database, or a customer named <script> ends up executing inside the renderer.

import 'server-only';

export type Invoice = {
  id: string;
  number: string;
  customer: { name: string; email: string };
  lines: { description: string; qty: number; unitCents: number }[];
  issuedAt: string;
};

const esc = (s: string) =>
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);

const money = (cents: number) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);

export function renderInvoiceHtml(inv: Invoice): string {
  const total = inv.lines.reduce((sum, l) => sum + l.qty * l.unitCents, 0);
  const rows = inv.lines
    .map((l) => `<tr><td>${esc(l.description)}</td><td class="num">${l.qty}</td>
      <td class="num">${money(l.unitCents)}</td><td class="num">${money(l.qty * l.unitCents)}</td></tr>`)
    .join('');

  return `<!doctype html>
<html><head><meta charset="utf-8">
<style>
  @page { size: Letter; margin: 0.6in; }
  body { font: 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; color: #111; }
  h1 { font-size: 22px; margin: 0 0 4px; }
  table { width: 100%; border-collapse: collapse; margin-top: 24px; }
  th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
  thead { display: table-header-group; }   /* repeat the header row on every page */
  tr { break-inside: avoid; }
  .num { text-align: right; }
  .total td { font-weight: 700; border-bottom: 0; }
</style></head>
<body>
  <h1>Invoice ${esc(inv.number)}</h1>
  <div>${esc(inv.customer.name)} &middot; ${esc(inv.customer.email)}</div>
  <div>Issued ${esc(inv.issuedAt)}</div>
  <table>
    <thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
    <tbody>${rows}</tbody>
    <tfoot><tr class="total"><td colspan="3">Total</td><td class="num">${money(total)}</td></tr></tfoot>
  </table>
</body></html>`;
}

@page { size: Letter } together with the preferCSSPageSize option lets the CSS decide the paper size and margins. thead { display: table-header-group } repeats the column headers on every page of a long invoice, and break-inside: avoid keeps rows from splitting across pages.

Step 4: a Route Handler that returns the PDF

This is the pattern most apps want: a URL such as /invoices/42/pdf that you can link to, open in a new tab, or download with ?download. In Next.js 15 and 16, params is a Promise, so await it. GET Route Handlers are not cached by default; the Cache-Control: private, no-store header keeps CDNs and shared caches from storing a customer document. getInvoice stands in for your own data layer (Prisma, Drizzle, a fetch to your backend).

import { getInvoice } from '@/lib/data';
import { renderInvoiceHtml } from '@/lib/invoice-html';
import { htmlToPdfBytes, Api2PdfError } from '@/lib/api2pdf';

export const maxDuration = 60; // seconds; give the render room on Vercel

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;

  // Check the session here: this route hands out the document.
  const invoice = await getInvoice(id);
  if (!invoice) return new Response('Not found', { status: 404 });

  try {
    const pdf = await htmlToPdfBytes(renderInvoiceHtml(invoice), {
      fileName: `invoice-${invoice.number}.pdf`,
      options: { preferCSSPageSize: true, printBackground: true },
    });

    const download = new URL(request.url).searchParams.has('download');
    return new Response(pdf, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="invoice-${invoice.number}.pdf"`,
        'Cache-Control': 'private, no-store',
      },
    });
  } catch (err) {
    const status = err instanceof Api2PdfError ? err.status : 0;
    console.error('PDF render failed', { id, status, err });
    return new Response('Could not generate the PDF', { status: 502 });
  }
}

Link to it like any other URL. Use a plain <a> rather than <Link>, because this is a file, not a page to prefetch:

<a href={`/invoices/${invoice.id}/pdf`} target="_blank" rel="noopener">View PDF</a>
<a href={`/invoices/${invoice.id}/pdf?download`}>Download PDF</a>

maxDuration is a route segment config option that Vercel uses as the function time limit. A typical invoice renders in a second or two, but large reports take longer, so set it comfortably above the client timeout (45 seconds above) and within what your Vercel plan allows.

Step 5: a Server Action that returns a hosted link

Sometimes you do not want the bytes to pass through your function at all, for example when a user clicks "Generate statement" and you also want to email the link or store it. Call the endpoint without outputBinary and return the FileUrl:

'use server';

import { getInvoice } from '@/lib/data';
import { renderInvoiceHtml } from '@/lib/invoice-html';
import { htmlToPdfFile } from '@/lib/api2pdf';

export async function createInvoicePdf(id: string): Promise<{ url: string } | { error: string }> {
  // Server Actions are public endpoints: authorize the caller here too.
  const invoice = await getInvoice(id);
  if (!invoice) return { error: 'Invoice not found' };

  try {
    const result = await htmlToPdfFile(renderInvoiceHtml(invoice), {
      fileName: `invoice-${invoice.number}.pdf`,
      options: { preferCSSPageSize: true },
    });
    return { url: result.FileUrl! };
  } catch {
    return { error: 'Could not generate the PDF. Please try again.' };
  }
}

And a Client Component that calls it with useTransition:

'use client';

import { useState, useTransition } from 'react';
import { createInvoicePdf } from './actions';

export function PdfButton({ invoiceId }: { invoiceId: string }) {
  const [pending, startTransition] = useTransition();
  const [result, setResult] = useState<{ url: string } | { error: string } | null>(null);

  return (
    <div>
      <button
        disabled={pending}
        onClick={() => startTransition(async () => setResult(await createInvoicePdf(invoiceId)))}
      >
        {pending ? 'Generating…' : 'Generate PDF'}
      </button>
      {result && 'url' in result && (
        <a href={result.url} target="_blank" rel="noopener noreferrer">Open PDF</a>
      )}
      {result && 'error' in result && <p role="alert">{result.error}</p>}
    </div>
  );
}

Rendering a link after the action finishes avoids popup blockers, which often block a window.open that runs after an await. Two things to remember about this pattern: Server Actions are reachable as public POST endpoints, so authorize inside the action, not only in the UI; and the FileUrl is a temporary link that expires after 24 hours. If the PDF has to live longer, download it into your own storage, or use useCustomStorage to have Api2Pdf PUT it straight into an S3 presigned URL (covered in Batch PDF generation: zip PDFs and upload to S3). For sensitive documents, delete the hosted copy as soon as you are done with DELETE /file/{responseId}.

Step 6: print an existing React page instead of a template

If you already have a polished invoice page built with your components, Tailwind and fonts, you do not have to rebuild it as a template string. Point /chrome/pdf/url at a print version of that page. Api2Pdf's browser has to be able to reach it, so it must be deployed (a localhost URL will not work) and it must not depend on the user's session cookie. Protect it with a short-lived HMAC token instead:

import 'server-only';
import { createHmac, timingSafeEqual } from 'node:crypto';

const secret = () => process.env.PDF_TOKEN_SECRET!;

export function signPrintToken(id: string, ttlSeconds = 300): string {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const sig = createHmac('sha256', secret()).update(`${id}.${exp}`).digest('base64url');
  return `${exp}.${sig}`;
}

export function verifyPrintToken(id: string, token: string | undefined): boolean {
  if (!token) return false;
  const [exp, sig] = token.split('.');
  if (!exp || !sig || Number(exp) < Date.now() / 1000) return false;
  const expected = createHmac('sha256', secret()).update(`${id}.${exp}`).digest('base64url');
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
import { notFound } from 'next/navigation';
import { getInvoice } from '@/lib/data';
import { verifyPrintToken } from '@/lib/print-token';

export default async function PrintInvoice({
  params,
  searchParams,
}: {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ token?: string }>;
}) {
  const { id } = await params;
  const { token } = await searchParams;
  if (!verifyPrintToken(id, token)) notFound();

  const invoice = await getInvoice(id);
  if (!invoice) notFound();

  return (
    <main className="invoice">
      <h1>Invoice {invoice.number}</h1>
      <p>{invoice.customer.name}</p>
      {/* the same components and CSS your app already uses */}
    </main>
  );
}

Then a Route Handler signs a token that is valid for five minutes, asks Api2Pdf to render the page, and waits for network activity to settle so web fonts, images and client-side data are in place before printing:

import { urlToPdfBytes } from '@/lib/api2pdf';
import { signPrintToken } from '@/lib/print-token';

export const maxDuration = 60;

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  // Check the session here before signing anything.

  const printUrl = new URL(`/invoices/${encodeURIComponent(id)}/print`, process.env.PUBLIC_APP_URL);
  printUrl.searchParams.set('token', signPrintToken(id));

  const pdf = await urlToPdfBytes(printUrl.toString(), {
    options: {
      printBackground: true,
      puppeteerWaitForMethod: 'WaitForNavigation',
      puppeteerWaitForValue: 'networkidle0', // wait for fonts, images and data
    },
  });

  return new Response(pdf, {
    headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'private, no-store' },
  });
}

puppeteerWaitForMethod: "WaitForNavigation" with puppeteerWaitForValue: "networkidle0" waits until the page has no network requests in flight. For pages that draw charts after the data loads, a delay in milliseconds is the simpler fallback. Put the token in the query string rather than in extraHTTPHeaders: custom headers are sent with every request the page makes, including requests to third-party font and image hosts.

Which variant to choose: the HTML template (step 4) is faster and has no moving parts, so it is the better default for invoices and receipts. The URL variant (step 6) wins when the document is a complex page you already maintain and want to keep pixel-identical to what users see.

Using the official npm package instead

The api2pdf package (version 2.1.0 on npm) wraps the same endpoints. Note how it behaves before dropping it into a Next.js app:

import Api2Pdf from 'api2pdf';

const client = new Api2Pdf(process.env.API2PDF_API_KEY!);

// JSON response with FileUrl
const result = await client.chromeHtmlToPdf(html, { filename: 'invoice.pdf', options: { landscape: true } });

// PDF bytes as a Node.js Buffer
const pdf = await client.chromeHtmlToPdf(html, { outputBinary: true });

// For large documents, point it at V2 XL
const xl = new Api2Pdf(process.env.API2PDF_API_KEY!, Api2Pdf.BaseUrls.V2Xl);
  • It uses Node's https module, so it runs in the Node.js runtime (the default for Route Handlers and Server Actions), not in the Edge runtime or middleware. The fetch client above runs in both.
  • When the API answers Success: false, the promise rejects with that JSON body, so wrap calls in try/catch rather than only checking result.Success.
  • It does not set a request timeout or retry for you, so add your own around it if you use it in a function with a hard time limit.

Large documents, cost and cleanup

  • Very large or slow documents. Hundred-page reports and image-heavy catalogs can exceed the standard limits. Set API2PDF_BASE_URL=https://v2-xl.api2pdf.com to use V2 XL (4 CPU, 8 GB of memory and a 5 minute timeout per request, at 10x the standard price), and raise timeoutMs and maxDuration to match.
  • Do not regenerate what has not changed. An issued invoice is immutable. Render it once, store the PDF in your own bucket, and serve it from there on later downloads.
  • Retries can bill twice. If a request times out on your side, Api2Pdf may still have finished the render. Keep the client timeout generous and the retry count low.
  • Pricing is pay as you go, based on the bandwidth of the output plus compute time; see pricing. The Cost field on every JSON response tells you what a call cost.
  • Cleanup. Hosted files expire after 24 hours. Call DELETE /file/{responseId} to remove one immediately.

If you are weighing this against running Chromium yourself, the numbers are in Puppeteer vs Playwright vs HTML-to-PDF API: the real cost of PDF generation.

Troubleshooting

SymptomLikely causeFix
401 with "Invalid API key"Wrong key, or a Bearer prefix in the headerSend the raw key in Authorization
402 with an x402Version bodyNo Authorization header at all, usually a missing env var in one environmentSet API2PDF_API_KEY in that Vercel environment and redeploy
Build error mentioning server-onlyA Client Component imports lib/api2pdf.tsCall it from a Route Handler or Server Action instead
Function times out on VercelmaxDuration lower than the render timeRaise maxDuration, or use V2 XL for heavy documents
URL variant renders a 404 or login pageThe print page is not public, is on localhost, or the token expiredUse your deployed URL and a fresh signed token
Missing fonts or blank chartsChrome printed before assets or data loadedUse networkidle0 or a delay, and inline critical fonts

Wrap-up

In the App Router, PDF generation fits in three small files: a server-only client, an HTML template, and a Route Handler or Server Action. Headless Chrome stays on Api2Pdf's side, so your Vercel functions stay small, cold starts stay quick, and you get the same rendering engine as Chrome's print preview. Grab an API key at portal.api2pdf.com, drop lib/api2pdf.ts into your project, and wire up one Route Handler. The full endpoint reference is in the interactive API docs.