Blog/SDKs

HTML to PDF in Next.js

SDKs··6 min read

Do not run a browser binary on Vercel. For next.js html to pdf, print from a Route Handler that calls RelayPDF. The official client is @relaypdf/sdk. It maps to POST /v1/pdf. The print engine lives on the API host. Your App Router handler sends UTF-8 HTML (or a public URL, Markdown, or a published template) and returns application/pdf. Product page: https://relaypdf.com/html-to-pdf. Why a function-hosted browser keeps breaking is a sibling: https://relaypdf.com/blog/serverless-chrome-vs-pdf-api.

Why the handler, not the function runtime

A Next.js app on Vercel is a short-lived Node process with a hard memory and time budget. A full browser does not fit that budget. Teams still try a local launch inside a Route Handler or a community package copied into the function. Those paths fail the same way: cold start, missing system libraries, killed workers. They also put a browser next to app code. This post does not document those installs. If you need the ops picture, read the sibling. The next.js html to pdf pattern that survives deploy is HTTP out, PDF back.

React is not the printer. A client component that runs html2pdf.js or jsPDF draws a canvas snapshot. That is a different file than print CSS from a hosted browser. It also puts any key you ship in the bundle. The Route Handler is the only place that should see RELAYPDF_API_KEY. Server Components can call the SDK too, but a POST route is the usual download path: the browser asks for a PDF, the handler talks to api.relaypdf.com, the user gets bytes.

Install and env

Node 18+ (global fetch). Package line is 0.1.x. Zero runtime dependencies. Docs: https://relaypdf.com/docs/sdks/node and https://relaypdf.com/docs/quickstart/node. Coding agents should run relaypdf setup and read RELAYPDF_API_KEY from the env file. In Vercel, set the same name as a project environment variable. Missing apiKey throws TypeError before any request. Do not prefix NEXT_PUBLIC_.

npm i @relaypdf/sdk

App Router Route Handler

app/api/pdf/route.ts is enough. Construct one RelayPDF client with the server key. Read HTML from the request body, or build it on the server from your data. fromHtml sends the string as JSON, UTF-8, not Base64. filename should end in .pdf. Default response is binary: kind is binary, bytes is a Uint8Array. Return those bytes with Content-Type application/pdf. The SDK does not retry. One method call is one HTTP request.

// app/api/pdf/route.ts

import { RelayPDF, RelayPDFError } from "@relaypdf/sdk";
const client = new RelayPDF({
  apiKey: process.env.RELAYPDF_API_KEY!,
});
export async function POST(request: Request) {
  const { html, filename } = await request.json();
  try {
    const pdf = await client.pdf.fromHtml(html, {
      filename: filename ?? "document.pdf",
      options: { format: "letter", printBackground: true },
    });

if (pdf.kind !== "binary") {

return Response.json({ error: "expected binary" }, { status: 500 });

    }

return new Response(pdf.bytes, {

      headers: {
        "Content-Type": pdf.contentType ?? "application/pdf",
        "Content-Disposition": "attachment; filename=document.pdf",
      },
    });
} catch (error) { if (error instanceof RelayPDFError) { return Response.json(
        { code: error.code, message: error.message },
        { status: error.status },
      );
    }

throw error;

  }
}

printBackground defaults to true on the API; set it if you want the contract explicit. format defaults to letter. A4 is the usual European page. options.timeout is the render budget, max 60000 ms, not an SDK HTTP timeout. waitUntil defaults to networkidle0. Use load, domcontentloaded, or networkidle2 if you have a reason. waitForSelector and waitForTimeout (max 30000 ms) exist for a late chart. headerTemplate and footerTemplate are margin fragments, not a clone of a React header tag. Placeholders: pageNumber, totalPages, date, title. Extra top and bottom margin so they are not clipped.

Call it from React

The client only needs fetch. Do not import @relaypdf/sdk in a Client Component. Build the HTML on the server when you can: a Server Component, a template string, or renderToStaticMarkup of a print-only tree. If the browser already has the markup, POST it to the handler. You are sending document HTML, not a key.

async function downloadPdf(html: string) {

  const res = await fetch("/api/pdf", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ html, filename: "invoice.pdf" }),
  });

if (!res.ok) throw new Error(await res.text());

  const blob = await res.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "invoice.pdf";
  a.click();
  URL.revokeObjectURL(url);
}

Pages Router still works: pages/api/pdf.ts with the same SDK call and a Buffer from pdf.bytes. App Router is the current default. Edge runtime is a poor fit if you expect Node-only APIs. The SDK uses native fetch and runs on Node 18+. Pin the route to the Node runtime if your project defaults to Edge.

Which source field

POST /v1/pdf accepts exactly one of html, url, markdown, or templateId. Mixing sources in one request is invalid_request. The Next handler should pick one.

You haveSDK method
A finished HTML stringclient.pdf.fromHtml
A public HTTPS pageclient.pdf.fromUrl
GitHub-flavoured Markdownclient.pdf.fromMarkdown
A published Handlebars layoutclient.pdf.fromTemplate

fromUrl fetches on RelayPDF side. Private, loopback, and metadata hosts return url_not_allowed. extraHTTPHeaders and cookies apply to that fetch, not to an html body you already assembled. If the page sits behind your own session, render HTML in the Route Handler and send html. Do not point the print host at localhost. Markdown is GFM through the print engine, not a ledger layout. fromTemplate takes a published UUID or slug plus templateData. Optional templateVersion pins a published version. strict: true fails missing Handlebars paths.

Binary, URL, async

Default response is application/pdf. Headers include x-relaypdf-id and x-relaypdf-size; the SDK maps those onto the result (id, sizeBytes, filename from content-disposition). Set response to url when the handler should return JSON with a public GET /v1/files/:id link. That download does not need the API key and expires in 24 hours. Set response to async for 202, then jobs.wait or jobs.get, or set callbackUrl to HTTPS. verifyWebhook needs the exact raw body string; do not parse and re-stringify. The secret is the dashboard webhook secret, not the API key. Header format is t=,v1=. Failed renders return render_failed and are not billed.

const result = await client.pdf.fromHtml(html, {
  response: "url",
  filename: "invoice.pdf",
});

if (result.kind === "url") {

return Response.json({ url: result.url, expiresAt: result.expiresAt });

}

Errors

API errors throw RelayPDFError. Branch on error.code. Failed operations are never billed. payment_required is an empty wallet. rate_limited carries retryAfter. payload_too_large is 413. convert_unavailable is a document worker cold or down and is unbilled. The Route Handler should forward status and code to the client, not a generic 500, so you can tell a bad HTML payload from an empty wallet.

What this is not

This is not a local browser launch recipe. It is not a community binary layer. It is not html2pdf.js in useEffect. Sibling Node (no Next): https://relaypdf.com/blog/html-to-pdf-nodejs. Office files are POST /v1/convert (client.convert.fromPath / fromHtml). Stitching PDFs is POST /v1/pdf/merge. Screenshots are POST /v1/images. Those belong in other routes if you need them. The print contract is https://relaypdf.com/docs/pdf. Shared options: https://relaypdf.com/docs/options.

Limits

RelayPDF will not fetch your Vercel preview behind auth unless you send cookies or headers on a public URL, or you send html you already rendered. Timeout max is 60 seconds of print time, which is enough for invoices and most reports and not enough for a five-minute dashboard. Files from a url response last 24 hours. There is no OCR, e-sign, or PDF/A on this path. Text extract reads an existing text layer. Pricing is a prepaid wallet; HTML/URL/Markdown PDF is listed on https://relaypdf.com/pricing. Signup credit is documented there. This post does not restate the table.

Ship the Route Handler, keep the browser off Vercel, and print from https://relaypdf.com/html-to-pdf.

Ready to generate?

One API for HTML, Markdown, URLs, and Office. REST, SDK, CLI, or MCP.