PDF Export
Exports a document to PDF by printing your own editor. The server opens it, read-only, in headless Chromium, so pagination, footnotes, headers/footers and table breaks come from the same layout engine as on screen. That layout is the server’s, though: a user on another browser or OS can see lines and pages break differently (see Limitations). 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
// docs: no-verify (an excerpt: the other StesuraEditor props are omitted)
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, experimental } (plus layout and docId when given) 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:
// docs: no-verify (an excerpt: myToast is your app's)
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.
To have the server check that the PDF paginates like the editor, give the
button the editor’s page starts, and the export page its own (layoutManifest
on ExportEditorShell, below). A difference is logged on the server and the
button warns:
// docs: no-verify (an excerpt)
import { paginationLayoutManifest } from "@stesura/pagination";
import { createPdfUiExtension } from "@stesura/pdf-export-client";
export const pdfExport = createPdfUiExtension({ getLayoutManifest: paginationLayoutManifest });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, useLocalEditor } from "@stesura/editor-react";
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} experimental={payload.experimental} />;
}
const Editor = ({
doc,
experimental,
}: {
doc: JSONContent;
experimental?: { columnSections: boolean };
}) => {
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({
// The exporting editor's experimental features, so columns lay out alike.
configuration: { config: { printMount: true, experimental } },
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 schema, the same plugins and
the same layout-affecting UI extensions (pagination, references,
createPdfViewer,createMermaidUiExtension…). A node the export page’s schema lacks can’t load, and any other 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,
experimental: body.experimental,
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, asynchronous node views 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). - Mark asynchronous rendering. The page waits for
<img>elements, web fonts and layout, and for any element carryingdata-render-pendingordata-resize-pending. A node view that fetches or draws its content later (equations, diagrams) setsdata-render-pendingon its root until the result or the error lands, and reports its new size withuseReportNodeResize, which covers the rest. Anything else may print as its placeholder. See Node views → Async rendering. - Prefer SVG to
<canvas>. Pages render at a device pixel ratio of 1, so a canvas prints at screen resolution.
Limitations
- The PDF is the reference rendering. Editor and export page load the same bundled font files, so Chromium-based browsers (Chrome, Edge) at 100% zoom show the same layout. Firefox and Safari shape text differently and can wrap lines, and with them page, table and footnote breaks, slightly differently. Layout has also been seen to shift with browser zoom.
- When the button is given
getLayoutManifest, the server compares the page starts the editor showed with the PDF’s. A difference is logged and comes back as alayoutloss (the button warns); the PDF keeps the server’s layout either way. - Characters the bundled fonts don’t cover (CJK, Arabic, rare symbols) print
in a font of the server’s OS and come back as a
glyphsloss. If a bundled font file fails to load, the export fails rather than print Latin text in a system font.