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.
| Package | Runtime | Contents |
|---|---|---|
@stesura/pdf-export-client | Browser | The toolbar button (pdfUiExtension, PdfExportGroup, PdfExportButton), the export page shell (ExportEditorShell, on /shell) and the protocol types (/types). |
@stesura/pdf-export-server | Node | createPdfExporter / 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: trueandprintModeare set. Plugins and node views readprintMountfrom state;printModecovers 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/dynamicwithssr: false): pagination only runs in a browser. - It reports a bad payload. The server waits for
data-export-readyordata-export-erroron<body>. A page that renders nothing on a missing payload leaves it waiting until the timeout; setdocument.body.dataset.exportErrorinstead, 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-coreto Next.js’sserverExternalPackages, and install Chromium on the server once:npx playwright install chromium. - Set
PDF_EXPORT_PAGE_ORIGINto the origin the server reaches the export page at.resolveExportPageOriginonly falls back to the request’s origin when that is loopback (local dev), and throws otherwise: theHostheader 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
429while 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/.internalhosts. A storage service on loopback (e.g. local Supabase on127.0.0.1:54321) is refused too. - Always allowed: the export page’s own host.
allowedHostslimits everything else to the listedhost[: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 option | Default | Bounds |
|---|---|---|
readyTimeoutMs | 60 s | Browser launch, navigation and the wait for the page to be ready. The page gives up by itself after 45 s. |
captureTimeoutMs | 30 s + 2 s per page | Positioning, printing and merging, after the page is ready. |
maxPages | 500 | Captured pages; checked before anything is printed. |
maxSubresourceBytes | 16 MiB | One asset response. |
maxSubresources | 1000 | Asset requests per render. |
maxEmbedBytes | 50 MiB | One 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
- The button posts the document JSON to your route.
- The server opens the export page in headless Chromium and injects the document before any script runs.
- The page lays out the document, waits for fonts, images and pagination to settle, then publishes the page list, link positions and heading tree.
- 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.
- 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()orisPrintMount(state), returningnullrather 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.