npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 chromium

Peer 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 Config

Add 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, not class. React's types reject class; Preact accepts both, so className is the spelling that satisfies React's typechecker and still emits class="…" 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.

  1. Scaffold the repo first, so there is somewhere for the document to land. Copy example/ — a config, a .tsx document, a stylesheet, and an assets directory — and check that tsx-to-pdf build runs before you change anything. Starting from a build that works means any later breakage is something you just did.
  2. 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.
  3. Upload your existing document (the zipped HTML, PDF, image, etc.) and ask it to recreate it as accurately as possible, editing content/*.tsx and content/styles.css and leaving the config alone.
  4. Iterate against the preview. Run tsx-to-pdf dev and 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 .woff2 files in content/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=woff2
  • Read 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 the chromium-headless-shell a 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_PATH overrides which browser is launched.
  • Tailwind scans every .tsx/.ts/.jsx/.js file 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