tsx-to-pdf
v1.5.0
Published
Generate a page-exact PDF from a document written in TSX and Tailwind, with optional Markdown, PNG and JPG output. Live preview at the exact page size, diffable HTML alongside, and a build that can be configured to fail when the layout overflows.
Maintainers
Readme
tsx-to-pdf
Write a document as JSX — React style, in a .tsx file — style it with Tailwind, and generate a page-exact PDF, with the rendered HTML alongside it and optional Markdown, PNG and JPG output.
Features a dev server to preview the document at its exact page size, which live-updates as you make changes. It serves the same HTML the PDF is rendered from, so the preview is not an approximation of the result.
Built for documents that have to fit a page: a resume, a one-pager, a leave-behind. The sheet is yours to choose — pageSize takes letter, legal, tabloid, a3, a4, a5, or explicit dimensions — and setting maxPages fails the build when the layout overflows, so "it still fits" is enforced rather than remembered.
Installation
npm i -D tsx-to-pdf preact playwright
npx playwright install chromiumPeer dependencies
| package | required | why it is yours |
| --- | --- | --- |
| preact | yes | The JSX runtime your document compiles against, and where its types come from (ComponentChildren and friends). A lightweight alternative to React, which suits a page that is static — no hooks, no providers, nothing shipped to a browser. |
| playwright | no | PDF, PNG and JPG output — omit it if you only want the HTML and CSS outputs (via --no-pdf). Can't be a dep of ours anyways — CLI has to be on your node_modules/.bin; it cannot run from a nested copy |
Getting started
Content
Two things are required: a document, and a config file. example/ is all of this filled in, if you would rather read a working one than a snippet.
// content/document.tsx
export const title = 'Firstname Lastname'
const Document = () => (
<div class="page font-serif">
<h1 class="text-2xl">{title}</h1>
<p class="pt-2">Anything you can write in JSX and Tailwind.</p>
</div>
)
export default Document// tsx-to-pdf.config.ts
import type { Config } from 'tsx-to-pdf'
export default {
entry: './content/document.tsx',
outDir: './outputs',
} satisfies ConfigAdd a stylesheet when you want your own theme, and an assets directory when the page loads something at render time — a font, a logo, etc. Neither is required: the .page class is the sheet itself, sized from your config, and Tailwind's utilities are compiled from whatever your JSX uses (for example, font-serif above resolves to Tailwind's own stack until you theme it).
// tsx-to-pdf.config.ts — with both
export default {
entry: './content/document.tsx',
outDir: './outputs',
styles: './content/styles.css',
assets: './content/assets',
name: 'resume',
} satisfies Config/* content/styles.css */
@theme {
--font-serif: "Gelasio", serif;
}Project config
"type": "module" in package.json. Without it your .tsx document is treated as CommonJS and fails with ERR_REQUIRE_CYCLE_MODULE.
That's it. You do not need a tsconfig.json at all, and if you have one, nothing in it has to be right: the JSX settings are layered on top at build time, so your paths, target and everything else still apply while jsxImportSource is taken care of. You also never compile your document — it is transformed as it loads.
A tsconfig is exported if you want your editor to match, but extending it is optional:
// tsconfig.json — optional
{ "extends": "tsx-to-pdf/tsconfig" }Executing
tsx-to-pdf dev # live preview at the exact page size
tsx-to-pdf build # writes outputs/html/, outputs/{name}.pdf (and .md/.png/.jpg with `--md`/`--png`/`--jpg`)Config
| key | default | what it does |
| --- | --- | --- |
| entry | required | .tsx module that default-exports the component and exports title |
| outDir | required | Where the rendered files land |
| styles | — | The document's stylesheet. Tailwind and the sheet are there without one |
| assets | — | Directory copied in beside the rendered page |
| name | entry's basename | Their basename: <name>.pdf, <name>.html, <name>.css |
| pageSize | 'letter' | letter, legal, tabloid, a3, a4, a5, or { width, height } as CSS lengths |
| margin | 1 | White space around the document, in inches: one number, or { top, right, bottom, left } with every side given |
| maxPages | unlimited | Building past this many pages fails. Set to 1 for a one-pager |
| checkPdfFontTypes | true | Fail when a font embeds as Type3, which extractors read poorly |
| author | — | /Author in the PDF: the person who wrote the document. This is where your own name goes |
| setDate | true | Stamp /CreationDate and /ModDate with the build time, a given Date, or false to omit them for a reproducible build. A rebuild that renders identical HTML and CSS skips printing altogether — no browser launch — and leaves the existing PDF as it is |
| port | 4000 | For tsx-to-pdf dev |
tsx-to-pdf build [--no-pdf] [--md] [--png] [--jpg] [--config <path>]
tsx-to-pdf dev [--port <n>] [--config <path>]--md, --png and --jpg each write an extra output alongside the rest for that build.
--no-pdf never launches a browser on its own: it writes the HTML and CSS, and the Markdown too if --md is also given. Those are a pure function of your sources, so rebuilding them is a fast, browserless way for CI to ask whether the document actually changed.
Markdown output
--md — or { markdown: true } to build() — writes <name>.md beside the rest, converted from the same HTML the PDF is generated from. It is the document's text, not the document. Headings, lists, links and emphasis survive because they are elements; everything this package exists to control does not, because it lives in classes Markdown cannot express — the sheet, the margins, columns, alignment, spacing, colour. A row that puts a job title on the left and its dates on the right comes out as two stacked blocks.
That makes it useful for the things that read text and ignore layout — a diff that shows what changed in the wording, an ATS or an LLM being handed a resume, a grep — and not for anything that has to look right. The PDF and the preview are the same render; the Markdown is a derivative of it.
Image output
--png/--jpg — or { png: true, jpg: true } to build() — write <name>.png/<name>.jpg beside the rest, a screenshot of the .page element from the same render the PDF is generated from, so it is pixel-for-pixel the page, not an approximation. A multi-page document — anything past maxPages: 1 — comes out as one tall image, the same way --md writes one file regardless of page count. It needs Playwright, same as pdf — no separate install.
Do you need to know React or Preact?
No. You are writing JSX — .tsx files — which is not React-specific. It is a syntax, consumed by React, Preact, Solid, Qwik and other JSX runtimes alike; which one compiles it is decided by jsxImportSource, and here that is settled for you. If you know React, you know how to write these documents.
Preact is the serializer: it turns your elements into an HTML string at build time. There are no hooks, no state, no hydration, and nothing ships to a browser — which is also why the runtime is an implementation detail rather than something you build against.
Using React types
You can typecheck your document against React while this still renders it. The JSX runtime is pinned at build time, so your own tsconfig can say whatever it likes:
// your tsconfig.json — this is fine
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" } }import type { ReactNode } from 'react'
const Section = ({ children }: { children: ReactNode }) => (
<h2 className="text-lg font-bold">{children}</h2>
)Two things to know:
- Use
className, notclass. React's types rejectclass; Preact accepts both, soclassNameis the spelling that satisfies React's typechecker and still emitsclass="…"in the HTML. - Don't import React values — a component from a React package produces React elements, which this cannot render. Type-only imports are erased before runtime and are always fine. If you need a React component library, alias it with
preact/compat.
Converting a document you already have
You almost certainly don't want to retype an existing document as JSX. Hand it to an LLM — Claude works well for this — and have it do the transcription.
- Scaffold the repo first, so there is somewhere for the document to land. Copy
example/— a config, a.tsxdocument, a stylesheet, and an assets directory — and check thattsx-to-pdf buildruns before you change anything. Starting from a build that works means any later breakage is something you just did. - Export your existing doc as HTML, if you can. Google Docs does this under File → Download → Web page (.html, zipped), and it is a much better input than a PDF: the markup carries the headings, fonts, sizes and margins, so the model reads your layout rather than inferring it. Upload the whole zip. A PDF or a screenshot works, but every measurement is then a guess and the conversion is less accurate.
- Upload your existing document (the zipped HTML, PDF, image, etc.) and ask it to recreate it as accurately as possible, editing
content/*.tsxandcontent/styles.cssand leaving the config alone. - Iterate against the preview. Run
tsx-to-pdf devand put it beside the original. Differences in spacing and type size are the usual ones, and they are quick to describe: "the header block is too tight", "the dates should be right-aligned with the bullets".
Two things worth doing yourself afterwards, since they are easy to get subtly wrong and the build will not catch them:
Fonts. Put real
.woff2files incontent/assets/and reference them from your stylesheet, rather than naming a font and hoping it resolves. Use static instances, not variable fonts — Chromium cannot embed a variable font in a PDF and silently falls back to Type3, which the build rejects. Generate them with fontTools:fonttools varLib.instancer Family[wght].ttf wght=400 \ --output=family-regular.woff2 --flavor=woff2Read the rendered text. Transcription errors land in dates, phone numbers and company names, which look plausible and are exactly the things a reader checks.
Programmatic use
The CLI is a thin wrapper around build and serve.
import { build, findConfig, loadConfig } from 'tsx-to-pdf'
import { register } from 'tsx/esm/api'
register() // needed only if your config or document is TypeScript
await build(await loadConfig(findConfig()))findConfig looks for tsx-to-pdf.config.{ts,mts,js,mjs} in the cwd, or takes an explicit path. build accepts { pdf, markdown, png, jpg } as a second argument — pdf: false is the equivalent of --no-pdf, markdown: true of --md, png/jpg: true of --png/--jpg.
Notes
- Chromium is installed separately (
playwright install chromium), since the browser is large and yours to manage. The full browser is pinned rather than thechromium-headless-shella headless launch would otherwise pick: the shell lays text out about 1.7% taller, which is enough to push a full page onto a second one on one machine and not the next. Don't install with--only-shell. CHROMIUM_EXECUTABLE_PATHoverrides which browser is launched.- Tailwind scans every
.tsx/.ts/.jsx/.jsfile in the entry's directory (recursively), not just the entry itself, so that a component split into its own file is still picked up. This also means that unrelated file in the dir gets scanned too— harmless to the PDF or HTML, since an unused class is just dead weight in the stylesheet, but worth knowing if looking at the output CSS file. - JSX escapes content by construction, so there is no raw-HTML path into the document. See Do you need to know React or Preact? for what the renderer is and isn't.
License
MIT
