Twenty One Media
webJune 17, 2026

Design the HTML for the PDF You're Going to Generate

The ops audit we send to prospects exists in two formats: a URL they open before the call, and a PDF they download or we attach to an email. Both come from the same Next.js page at /audit/[slug]. We don't maintain two templates. One HTML document serves both modes.

That only works cleanly if you make the right decisions when writing the page, not when you write the Playwright script.

The Background Problem

Our site uses a dark theme. The audit document should be white. If you let the audit page inherit the site's background and then run Playwright with printBackground: true, you get a dark PDF. That's wrong.

The fix is explicit:

<article className="min-h-screen bg-white text-neutral-900">

bg-white is not a reset or an override. It's an intentional choice made at design time: this page is a document, not a site page. The background is part of the document, so it has to be declared. printBackground: true in the Playwright call means backgrounds render in the PDF. That's what you want when you have colored sections like the dark summary block and the blue CTA border.

Page Break Control

PDFs paginate. HTML doesn't. A workflow card that starts near the bottom of a page can split across two pages, cutting the estimate range from its label. That's illegible.

Every card in the audit uses break-inside-avoid:

<div className="rounded-lg border border-neutral-200 p-5 break-inside-avoid">

The summary block, the path section, and the CTA all carry the same class. They're designed to stay on one page. If a card doesn't fit on the current page, the PDF engine starts it on the next one. You don't need @media print rules for this. break-inside-avoid works in both screen rendering and Playwright's headless Chromium.

Stripping the Site Chrome

The bigger problem is the site layout. Every page on twenty1-media.com loads with a fixed nav header above <main> and a footer below it. Both are injected by the root layout. The audit page can't remove them from JSX because it doesn't own the layout.

The Playwright script handles this with an injected CSS block:

const CHROME_STRIP_CSS = `
  header:not(main header),
  footer:not(main footer),
  [class*="fixed"][class*="h-[2px]"] {
    display: none !important;
  }
  main.pt-16 {
    padding-top: 0 !important;
  }
`;

await page.addStyleTag({ content: CHROME_STRIP_CSS });

The selector header:not(main header) is doing specific work. The site nav header is a sibling of <main> in the layout. The audit page renders its own <header> inside <article>, which is inside <main>. main header selects only the audit's own header, so not(main header) targets only the site nav. Same logic for the footer. The audit's disclaimer line at the bottom survives. The site footer disappears.

The third selector, [class*="fixed"][class*="h-[2px]"], hides the scroll progress bar, a fixed-position gradient div that renders on top of the page in the browser and would appear as a colored stripe across the top of the PDF without this.

The last rule removes pt-16 from <main>. That padding exists to clear the fixed nav in the browser. In the PDF, the nav is hidden, so the padding creates a blank gap at the top of the first page.

Why Injection Instead of a Print Stylesheet

The alternative is a @media print stylesheet in the component or the layout. The problem is scope: a print stylesheet applied globally to hide the nav would affect every page that prints from the browser, not just the audit page. And we'd have to ship that CSS as part of the site.

Playwright CSS injection scopes the changes to one script. The audit page ships with no print-specific styles. The Playwright script knows it's rendering an audit page and applies exactly the rules needed for that context. Everything is colocated with the script that uses it.

The Result

One command renders the PDF:

node scripts/render-audit-pdf.mjs sample-business-x0x0

Playwright opens Chromium, navigates to the audit page, waits for network idle, injects the strip CSS, and calls page.pdf() with Letter format and 0.5-inch margins. The output is a clean, white, properly paginated document that looks like it was designed in InDesign.

The page itself looks nothing like an InDesign file. It's Tailwind classes and a few hundred lines of JSX. But the design decisions in those classes, white background, explicit break points, no site-specific chrome, produce a PDF that doesn't need post-processing.

The discipline is: design the HTML knowing a script will render it as a PDF. Then the script is just a few lines.