Frontend Print / PDF — Persimmon Patterns
For invoices, receipts, contracts, legal filings, and reports. Two distinct mechanisms — pick by where the PDF is consumed:
- Browser print — user clicks a button, the browser print dialog opens, they choose "Save as PDF". Zero server cost, zero new dependencies. Covers 95% of "let the user print/save this page" needs.
- Server-side PDF — the server produces a
.pdfbyte stream. Required for email attachments, batch generation, archival to a bucket, or any flow where no human is at a browser.
Default to browser print. Escalate to server-side only when the consumer is a machine, a mailbox, or an archive.
Core Rules
- The page being printed is a normal RSC route. Only the print trigger (
window.print()) needs a"use client"component — keep it a small leaf, not the whole page. - Print styles live in two places: Tailwind
print:variants for per-element tweaks, and one dedicatedprint.css(imported once) for@page, table-header-group, and color-adjust rules that have no clean Tailwind variant. - Every interactive/chrome element gets
print:hidden. Every print-only element getshidden print:block. - Status badges and brand colors that must survive print get
print-color-adjust: exact— Chrome strips backgrounds otherwise. - Multi-page tables get
thead { display: table-header-group }so headers repeat. There is no Tailwind variant for this; it lives inprint.css. - Server-side PDFs are persisted, never regenerated on page load — store the bytes in the bucket (see
infra-s3-uploads) and serve a presigned URL. Regenerating an invoice PDF on every view is expensive and non-deterministic. - Never put PII or document contents in logs when generating server-side PDFs.
Mechanism 1 — Browser print
The print trigger (client component)
window is browser-only, so the button is the one "use client" island. The document itself stays an RSC.
// src/components/PrintButton.tsx
"use client";
import { Printer } from "lucide-react";
export function PrintButton() {
return (
<div className="print:hidden">
<button
type="button"
=> window.print()}
className="inline-flex items-center gap-2 rounded-md bg-ink px-4 py-2 text-sm text-paper"
>
<Printer className="size-4" aria-hidden />
Imprimir / Salvar PDF
</button>
<p className="mt-1 text-sm text-muted">
Na caixa de impressão, escolha “Salvar como PDF” como destino.
</p>
</div>
);
}
Safari hides the PDF option under a "PDF" dropdown bottom-left; the hint copy above is mandatory so those users find it.
The printable page (RSC)
// src/app/processes/[id]/invoice/page.tsx
import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { PrintButton } from "@/components/PrintButton";
// Reads DB + auth at request time — MANDATORY or the Railway build prerenders and fails.
export const dynamic = "force-dynamic";
export default async function InvoicePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) notFound();
const invoice = await db.invoice.findFirst({
where: { id, ownerId: session.user.id },
include: { items: true, client: true },
});
if (!invoice) notFound();
return (
<main className="mx-auto max-w-3xl p-6">
<PrintButton />
<article className="print-doc mt-6">{/* invoice markup, see skeleton */}</article>
</main>
);
}
Tailwind v4 print layer + print.css
Tailwind's print: variant maps to @media print. Use it for per-element visibility and spacing. Put the rules that have no variant (@page, table-header-group, print-color-adjust) into a dedicated stylesheet imported once in the root layout.
/* src/app/print.css — imported once in app/layout.tsx */
@media print {
@page {
size: A4; /* or letter — override per document */
margin: 15mm 12mm;
}
@page :first { margin-top: 8mm; }
/* Kill UI noise the print: variant can't reach generically */
* { box-shadow: none !important; text-shadow: none !important; }
body { color: #000; }
/* Preserve intentional color — Chrome strips backgrounds without this */
.keep-color { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* Repeat table headers across pages — no Tailwind variant exists */
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
table { border-collapse: collapse; width: 100%; page-break-inside: auto; }
tr, td, th { page-break-inside: avoid; }
/* Don't orphan headings; keep blocks intact */
h1, h2, h3 { page-break-after: avoid; }
.signature-block, .totals-row, figure { page-break-inside: avoid; }
/* Reveal an href after opt-in external links */
a.print-url::after { content: " (" attr(href) ")"; font-size: 9pt; color: #555; }
/* Page-break utilities */
.page-break-before { page-break-before: always; }
.page-break-after { page-break-after: always; }
}
// src/app/layout.tsx
import "./globals.css";
import "./print.css";
Per-element, prefer Tailwind variants over expanding print.css:
<nav className="print:hidden">…</nav>
<aside className="print:hidden">…</aside>
<p className="hidden print:block">Confidential — generated {date}</p>
<span className="keep-color rounded-full bg-emerald-100 px-2 text-emerald-800">Pago</span>
<table className="print:text-[10pt]">…</table>
Color tokens (bg-ink, text-paper, badge colors) come from stack-tailwind-tokens. Print styles use absolute units (pt, mm) — tokens are for screen.
Invoice / report skeleton
<article className="print-doc">
<header className="flex items-start justify-between">
<img src="/logo.svg" alt="" width={160} className="h-12 w-auto" />
<div className="text-right">
<h1 className="text-xl">Fatura #INV-2026-0042</h1>
<p className="text-sm">Emitida: 25/05/2026<br />Vencimento: 25/06/2026</p>
</div>
</header>
<section className="mt-8 grid grid-cols-2 gap-6">
<div><h2 className="text-sm font-semibold">Para</h2><p>Acme Ltda…</p></div>
<div><h2 className="text-sm font-semibold">De</h2><p>Persimmon…</p></div>
</section>
<table className="mt-6">
<thead>
<tr><th>Descrição</th><th>Qtd</th><th>Valor</th><th className="text-right">Total</th></tr>
</thead>
<tbody>
<tr><td>Análise processual</td><td>40</td><td>R$ 150</td><td className="text-right tabular-nums">R$ 6.000,00</td></tr>
</tbody>
<tfoot>
<tr className="totals-row font-bold"><th colSpan={3}>Total</th><td className="text-right">R$ 6.000,00</td></tr>
</tfoot>
</table>
<section className="signature-block mt-12">
<p>Condições: 30 dias. Multa após o vencimento.</p>
<div className="mt-8 flex gap-12">
<p>Assinatura: ________________________</p>
<p>Data: ___________</p>
</div>
</section>
</article>
The same markup is the screen view and the print view — one source of truth. Give .print-doc a max-w-3xl mx-auto bg-white on screen so the preview matches the printed page.
Page-break decision tree
| Element | Rule | How |
|---|---|---|
| Section heading at page bottom | Don't orphan | print.css h2,h3 { page-break-after: avoid } |
| Totals row | Don't split from rows above | .totals-row { page-break-inside: avoid } |
| Signature block | Stay together | .signature-block { page-break-inside: avoid } |
| Table longer than a page | Repeat header | thead { display: table-header-group } |
| Cover page / appendix | Force a break | .page-break-after / .page-break-before |
Mechanism 2 — Server-side PDF
Escalate only when no human is at a browser. Two Persimmon-fit options:
Option A — @react-pdf/renderer (structured documents)
Build the PDF from React primitives (Document, Page, View, Text). Best when the layout is data-driven and tabular (invoices, statements, scorecards) and you don't need pixel parity with your web CSS. No headless browser, lower memory, runs in a Route Handler.
// src/lib/pdf/invoice-pdf.tsx
import { Document, Page, Text, View, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
const s = StyleSheet.create({
page: { padding: 40, fontSize: 10 },
row: { flexDirection: "row", justifyContent: "space-between" },
});
export async function renderInvoicePdf(invoice: {
number: string;
total: string;
}): Promise<Buffer> {
const doc = (
<Document>
<Page size="A4" style={s.page}>
<View style={s.row}>
<Text>Fatura #{invoice.number}</Text>
<Text>{invoice.total}</Text>
</View>
</Page>
</Document>
);
return renderToBuffer(doc);
}
// src/app/api/invoices/[id]/pdf/route.ts — API route is correct here: byte stream, not a mutation
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { renderInvoicePdf } from "@/lib/pdf/invoice-pdf";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user?.id) return new Response("Unauthorized", { status: 401 });
const invoice = await db.invoice.findFirst({ where: { id, ownerId: session.user.id } });
if (!invoice) return new Response("Not found", { status: 404 });
const pdf = await renderInvoicePdf({ number: invoice.number, total: "R$ 6.000,00" });
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="fatura-${invoice.number}.pdf"`,
},
});
}
A Route Handler is the right home for PDF bytes — it's a non-mutation read returning a binary stream, exactly the carve-out from the Server-Actions rule.
Option B — Playwright headless render (reuse your web CSS)
Render an existing print route to PDF with headless Chromium. Best when you want the PDF to look exactly like the browser-print output — same Tailwind, same print.css, one layout for screen + browser-print + server-PDF.
// src/lib/pdf/render-route.ts — runs on the Node runtime, NOT edge
import { chromium } from "playwright";
export async function renderRouteToPdf(url: string, cookie: string): Promise<Buffer> {
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.context().addCookies([{ name: "next-auth.session-token", value: cookie, url }]);
await page.goto(url, { waitUntil: "networkidle" });
return await page.pdf({ format: "A4", printBackground: true, margin: { top: "15mm", bottom: "15mm" } });
} finally {
await browser.close();
}
}
Railway caveats: Playwright needs the Chromium binary + system libs in the image (npx playwright install --with-deps chromium in the build) and meaningfully more memory than @react-pdf/renderer. Use the Node runtime, never edge. For batch jobs, run it from a queue worker, not inside a request that a user is waiting on.
Picking A vs B
| Need | Pick |
|---|---|
| Data-driven invoice/statement, no CSS parity needed | A — @react-pdf/renderer |
| Must match the on-screen/browser-print layout exactly | B — Playwright |
| Lowest memory / no browser binary on Railway | A |
| Charts, complex CSS, web fonts already styled | B |
| One-off "print this page" for a logged-in user | Neither — browser print |
Persist, don't regenerate
For anything emailed or archived: generate once, PutObject to the bucket, store the key, serve a presigned URL on subsequent views. See infra-s3-uploads for the bucket + presigned-URL flow. Regenerating on every request burns CPU and (for Playwright) a browser launch per view.
One-Screen Defaults
- Default to browser print (
window.print()in a tiny"use client"button). - Printable page is a normal RSC with
export const dynamic = "force-dynamic"if it reads DB/auth. - Per-element print tweaks → Tailwind
print:variants.@page/table-header-group/print-color-adjust→ one importedprint.css. print:hiddenon chrome;hidden print:blockon print-only;.keep-color+print-color-adjust: exacton badges.- Server-side only when the consumer is a mailbox, a batch, or an archive.
@react-pdf/rendererfor structured docs; Playwright when you must match web CSS. - Server-side PDFs: serve from a Route Handler, persist to the bucket, never regenerate on load, never log document contents.
Anti-patterns banned
- Marking the whole printable page
"use client"just to callwindow.print()— only the button needs it. - Reaching for server-side PDF for a single per-user "print this page" — browser print is enough.
- A print stylesheet with no
@page { margin }— default browser margins are too generous. - Forgetting
thead { display: table-header-group }— multi-page tables lose their headers. - Forgetting
print-color-adjust: exacton badges / brand bars — Chrome strips the backgrounds. - Print button with no "Save as PDF" hint — Safari users can't find the option.
- Hardcoding
width: 210mmon the document body — let@pageown the sizing. - Missing
page-break-inside: avoidon totals rows / signature blocks — they split across pages. - Generating a server-side PDF on every page view instead of persisting it to the bucket.
- Running Playwright on the edge runtime, or inside a request a user is waiting on for batch work.
- Using a Server Action to return PDF bytes — byte streams belong in a Route Handler.
Cross-References
stack-tailwind-tokens— color/spacing tokens used in the screen view; print uses absolute units.frontend-page-templates— the page shell the printable document lives inside.infra-s3-uploads— bucket + presigned-URL flow for storing generated server-side PDFs.stack-server-actions— why PDF byte streams use a Route Handler, not an action.
Sources: MDN — page-break-inside, MDN — print-color-adjust, Tailwind v4 — print variant, @react-pdf/renderer docs, Playwright — page.pdf().