Skip to Content
Import / ExportPDF Export

PDF Export

Exports a document to a PDF that matches the editor page for page. The server opens your own editor, read-only, in headless Chromium and prints it, so pagination, footnotes, headers/footers and table breaks come out exactly as on screen. Text stays selectable, and links, cross-references and headings become PDF links and bookmarks.

PackageRuntimeContents
@stesura/pdf-export-clientBrowserThe toolbar button (pdfUiExtension, PdfExportGroup, PdfExportButton), the export page shell (ExportEditorShell, on /shell) and the protocol types (/types).
@stesura/pdf-export-serverNodecreatePdfExporter / renderDocToPdf (Chromium via playwright-core, merging via pdf-lib) and helpers for the route.

Setup has three parts: the button, the export page, and the server route.

1. Toolbar button

import { pdfUiExtension } from "@stesura/pdf-export-client"; <StesuraEditor uiExtensions={[pdfUiExtension /* , … */]} />;

This adds a PDF Export button to the toolbar’s Export tab. It posts { doc, fileName } to ${NEXT_PUBLIC_BASE_PATH}/api/export/pdf and downloads the PDF it gets back. For another endpoint, file name or error handling, mount the button yourself:

import { PdfExportButton } from "@stesura/pdf-export-client"; <PdfExportButton endpoint="https://export.example.com/pdf" fileName="report.pdf" onError={(error) => myToast(error.message)} // replaces the default toast />;

A response that is not application/pdf counts as a failure, so a proxy’s HTML error page is never downloaded as a broken .pdf.

2. Export page

The server loads this page in Chromium. You own the editor mount, so it runs your plugins, node views and extensions; ExportEditorShell provides the clipping viewport and the readiness signal the server waits for.

"use client"; import type { JSONContent } from "@stesura/core/types"; import { StesuraEditor } from "@stesura/editor-react"; import { useLocalEditor } from "@stesura/editor-react/hooks"; import { ExportEditorShell } from "@stesura/pdf-export-client/shell"; import { useEffect } from "react"; export default function PrintEditor() { // Injected by the server before any page script runs. const payload = window.__STESURA_EXPORT_DOC__; if (!payload?.doc) return <MissingPayload />; return <Editor doc={payload.doc} />; } const Editor = ({ doc }: { doc: JSONContent }) => { const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({ configuration: { config: { printMount: true } }, extraPlugins: () => [/* the same plugins as your editor */], initialContent: doc, }); return ( <ExportEditorShell> <StesuraEditor printMode editable={false} toolbar={false} state={editorState} dispatchTransaction={dispatch} schema={schema} pluginFactory={pluginFactory} uiExtensions={uiExtensions} // the same layout-affecting extensions as your editor userPreferences={{ zoomLevel: 100 }} /> </ExportEditorShell> ); }; const MissingPayload = () => { useEffect(() => { document.body.dataset.exportError = "No export payload"; }, []); return null; };

Serve it at a stable route (e.g. /export/print) and make sure that:

  • It lays out like the editor. Use the same plugins and the same layout-affecting UI extensions (pagination, references, createPdfViewer…). Any difference moves page breaks.
  • Both printMount: true and printMode are set. Plugins and node views read printMount from state; printMode covers the first render, before the state exists. Together they keep screen-only chrome (handles, menus, outlines) out of the capture.
  • It renders client-side only (next/dynamic with ssr: false): pagination only runs in a browser.
  • It reports a bad payload. The server waits for data-export-ready or data-export-error on <body>. A page that renders nothing on a missing payload leaves it waiting until the timeout; set document.body.dataset.exportError instead, as above.
  • It needs no login and doesn’t redirect. The headless browser carries no cookies, and the server fails the export if the page redirects to another host (e.g. a login page). Exclude the route from auth middleware; the document is injected, so the page itself serves no data.
  • Your own overlays are hidden. The shell’s stylesheet hides the editor packages’ chrome. Anything your layout adds (floating buttons, dev widgets) prints over the page unless you hide it under html[data-stesura-print]:
/* imported from the export route */ html[data-stesura-print] .my-floating-toolbar { display: none !important; }

Don’t import @stesura/pdf-export-client/print.css yourself: the shell does, and it must only load on this route, since its @page rule would also apply to your app’s own printing.

3. Server route

import { contentDispositionAttachment, createPdfExporter, PdfExportRequestError, readPdfExportRequest, resolveExportPageOrigin, sanitizeFileName, type PdfExporter, } from "@stesura/pdf-export-server"; export const runtime = "nodejs"; const EXPORT_TIMEOUT_MS = 90_000; const MAX_BODY_BYTES = 20 * 1024 * 1024; // One Chromium shared across requests, launched on the first render. Kept on // globalThis so dev-server reloads reuse it instead of leaking a browser. const getExporter = (): Promise<PdfExporter> => { const g = globalThis as typeof globalThis & { __pdfExporter?: Promise<PdfExporter> }; g.__pdfExporter ??= createPdfExporter(); return g.__pdfExporter; }; export async function POST(request: Request) { let body; try { // Stops reading at the cap, and validates the body's shape. body = await readPdfExportRequest(request.body, { maxBytes: MAX_BODY_BYTES }); } catch (err) { if (err instanceof PdfExportRequestError) { return Response.json({ error: err.message }, { status: err.status }); } throw err; } const fileName = sanitizeFileName(body.fileName); const origin = resolveExportPageOrigin( process.env.PDF_EXPORT_PAGE_ORIGIN, new URL(request.url).origin ); const exporter = await getExporter(); try { const pdf = await exporter.render({ doc: body.doc, fileName, exportPageUrl: new URL("/export/print", origin).toString(), allowedHosts: ["files.example.com"], // see "Network egress" signal: AbortSignal.timeout(EXPORT_TIMEOUT_MS), }); return new Response(Buffer.from(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": contentDispositionAttachment(fileName), }, }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { return Response.json({ error: "PDF export timed out" }, { status: 504 }); } throw err; } }

Deployment notes:

  • Node runtime only. Add playwright-core to Next.js’s serverExternalPackages, and install Chromium on the server once: npx playwright install chromium.
  • Set PDF_EXPORT_PAGE_ORIGIN to the origin the server reaches the export page at. resolveExportPageOrigin only falls back to the request’s origin when that is loopback (local dev), and throws otherwise: the Host header is client-controlled, and the export page’s host is trusted by the egress policy.
  • Include your base path in exportPageUrl: new URL("/export/print", origin) drops it.
  • Protect the route with auth and rate limiting. It runs a browser on caller-supplied input.
  • Decide on concurrency. The exporter doesn’t queue: concurrent renders share one browser and compete for CPU. Queue them, answer 429 while one is running, or run one exporter per worker.

Network egress

Every image, font and file URL in the document becomes a request from your server, and the document is caller-supplied. So every render filters requests, whether or not you configure anything:

  • Always refused: non-http(s) URLs, loopback, private and link-local ranges (including the cloud-metadata endpoint), CGNAT, IPv6 ULA, and .local / .internal hosts. A storage service on loopback (e.g. local Supabase on 127.0.0.1:54321) is refused too.
  • Always allowed: the export page’s own host.
  • allowedHosts limits everything else to the listed host[:port]s. An empty array allows only the export page’s host; omitting it allows any public host.

The exporter fetches these requests itself instead of letting Chromium do it, which lets it:

  • re-check every redirect hop (at most 5 requests per asset);
  • check the addresses a host resolves to before connecting, which stops DNS rebinding (every host except the export page’s own);
  • cap each response (maxSubresourceBytes) and the number of requests per render (maxSubresources).

Requests carry content negotiation headers, the user agent, Referer and Origin, but never cookies or authorization, so assets that need the user’s session can’t be loaded. Responses keep their headers, CORS included. Service workers are blocked.

The export page’s own navigation is trusted by name, which is why its origin is configuration. If the exporter must hold against hostile documents, also restrict egress at the network level on the Chromium host.

Embedded PDFs

With @stesura/pdf-viewer-react, a previewed PDF file is not captured as a screenshot. The server fetches the file under the same egress rules and splices its real pages in after the page the preview follows, rotated as in the preview. Links, form fields and scripts are stripped from those pages. A file that is refused, larger than maxEmbedBytes, encrypted, unreadable, or would push the export past maxPages is left out, with a warning in the server log. A PDF shown as a card prints as a card.

The export page must mount createPdfViewer too, so its pagination leaves the same space as the editor’s. No preview renders there, so the export’s Chromium never downloads the file itself.

Timeouts and limits

render optionDefaultBounds
readyTimeoutMs60 sBrowser launch, navigation and the wait for the page to be ready. The page gives up by itself after 45 s.
captureTimeoutMs30 s + 2 s per pagePositioning, printing and merging, after the page is ready.
maxPages500Captured pages; checked before anything is printed.
maxSubresourceBytes16 MiBOne asset response.
maxSubresources1000Asset requests per render.
maxEmbedBytes50 MiBOne embedded PDF file.

For the route’s own deadline, pass signal. It closes the page, rejects with an AbortError, and render only settles once the page is closed, so a lock released after await render(...) is never released early. Racing render against your own timer instead leaves the render running.

createPdfExporter vs renderDocToPdf

renderDocToPdf launches Chromium, renders once, and closes it. Fine for occasional exports.

createPdfExporter keeps one Chromium for many renders. It launches on the first render, or at boot with warmup(), and gives each render a fresh context and page, so nothing carries over between exports. Only the process stays warm: the HTTP cache is off, so assets are fetched again for every export. Call close() to shut it down. Full surface: @stesura/pdf-export-server.

How it works

  1. The button posts the document JSON to your route.
  2. The server opens the export page in headless Chromium and injects the document before any script runs.
  3. The page lays out the document, waits for fonts, images and pagination to settle, then publishes the page list, link positions and heading tree.
  4. Consecutive same-size pages of a section are printed in one pass, as one tall sheet the server slices into pages. If their spacing can’t be verified, they are printed one at a time. Each print covers exactly the pages’ area, so Chromium never re-breaks the layout.
  5. Pages are merged as they are captured, embedded PDFs are spliced in, and links and bookmarks are added. Relative links (/path, ./file) are dropped: there is no public base URL to resolve them against.

For debugging, set PDF_EXPORT_HEADED=1 to watch the browser (or pass createPdfExporter({ headless: false })), PDF_EXPORT_PROFILE=1 for timing logs, or PDF_EXPORT_NO_BATCH=1 to print one page at a time.

Custom node views

The export runs your node views, so they print as they render. To keep them faithful:

  • Leave out interactive chrome with usePrintMode() or isPrintMount(state), returning null rather than hiding it with CSS (see Node views).
  • Render content synchronously. The page waits for <img> elements, web fonts and layout to settle before capturing, but nothing else: content a node view fetches or draws asynchronously may print as its placeholder.
  • Prefer SVG to <canvas>. Pages render at a device pixel ratio of 1, so a canvas prints at screen resolution.

Limitations

  • The PDF matches Chromium’s layout. Users editing in another browser may see small differences on screen.
  • Fonts the document uses must be available to the server’s Chromium, ideally self-hosted, or text reflows.
Last updated on