Skip to content

HTML to PDF in Next.js API routes

, 5 min read

In a Next.js app the PDF call belongs on the server, so the API key never reaches the browser. This guide uses an App Router route handler, which works the same on any Node.js host.

A route that returns a PDF

app/invoices/[id]/pdf/route.ts
export const runtime = "nodejs";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const html = await renderInvoiceHtml(id); // your own templating

  const res = await fetch("https://api.pagenull.dev/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PAGENULL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, format: "A4" }),
    cache: "no-store",
  });
  if (!res.ok) {
    const { error } = await res.json();
    return Response.json({ error: error.message }, { status: 502 });
  }

  return new Response(res.body, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `inline; filename="invoice-${id}.pdf"`,
    },
  });
}
  • PAGENULL_API_KEY has no NEXT_PUBLIC_ prefix, so it stays on the server.
  • new Response(res.body) streams the PDF through instead of buffering it.
  • Run your normal auth check first: the route should only serve a user’s own invoices.

HTML from a React component

If the invoice is already a React component, render it to a string on the server with renderToStaticMarkup and wrap it in a full document with your print CSS:

lib/invoice-html.tsx
import { renderToStaticMarkup } from "react-dom/server";
import { InvoiceDocument } from "@/components/invoice-document";

export function invoiceHtml(invoice: Invoice) {
  const body = renderToStaticMarkup(<InvoiceDocument invoice={invoice} />);
  return `<!doctype html><html><head><meta charset="utf-8"><style>${PRINT_CSS}</style></head><body>${body}</body></html>`;
}

Inline the CSS, or link a stylesheet by its public URL. With Tailwind, link your built stylesheet from your public site so the classes have their CSS.

Pages Router

pages/api/invoices/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const upstream = await fetch("https://api.pagenull.dev/v1/pdf", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.PAGENULL_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ html: await renderInvoiceHtml(String(req.query.id)), format: "A4" }),
  });
  if (!upstream.ok) return res.status(502).json(await upstream.json());
  res.setHeader("Content-Type", "application/pdf");
  res.send(Buffer.from(await upstream.arrayBuffer()));
}

Things to know

  • A long document can take a second or more to render. If your host has a very short function timeout, raise it for this route.
  • Responses carry a pagenull-receipt header. Store or forward it if you may need to prove later what was produced: see proving a PDF wasn’t altered.
  • Use "test": true in development: watermarked, free, not counted.