Print-Perfect PDF Pages in Nuxt
Bolting print styles onto a screen page produces bad PDFs. Building a separate A4 page produces good ones. The techniques, and the traps a dark theme sets for you.
I needed my CV as a web page and as a PDF. The obvious approach — one page, a @media print block — produced a genuinely bad PDF.
The fix was to stop trying. Two routes, two layouts, one data source. It is a better pattern than it sounds, and the reasons are worth spelling out.
Why print styles on a screen page fail
The screen page was built for a dark theme at 1200px+ with generous section padding. Print needs white paper at 210mm with tight spacing. Overriding one into the other means fighting every decision the screen layout made.
Concretely, from the version I threw away:
- Section padding of
py-16had to be crushed to0.55rem max-w-5xlkept content boxed inside the paper margin, wasting width- Every dark-theme colour needed an explicit light override
- A
ring-4in the theme background painted dark squares on white paper - The fixed header was still in flow, and the hero's header offset came with it
Each fix is an !important fighting a utility class. The result was fragile — every screen tweak risked the PDF, invisibly, because nobody looks at print preview after a CSS change.
The killer was subtler. I had written section { break-inside: avoid }, intending "don't split a job entry". Applied to a section, it means "don't split this entire section", so the whole experience block tried to fit on one page and pagination fell apart.
The pattern that works
app/data/cv.ts → single source of truth
app/pages/cv/index.vue → screen page, site theme
app/pages/cv/print.vue → A4 document
app/layouts/print.vue → <slot /> and nothing else
The blank layout is the important bit:
<template>
<slot />
</template>
No header, no footer, no navigation. Nothing to hide, so nothing to fight. Then in the page:
definePageMeta({ layout: 'print' })
Both pages import the same data module, so content cannot drift. Update a job description once, both surfaces change.
One routing gotcha: pages/cv.vue and pages/cv/print.vue cannot coexist — the first becomes a parent route needing <NuxtPage />. Move it to pages/cv/index.vue.
Render the paper on screen
The technique that makes this pleasant: draw the actual sheet in the browser.
.page {
display: flex;
width: 210mm;
height: 297mm;
margin: 0 auto;
background: #fff;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
}
A white A4 rectangle on a grey backdrop. Now what you see while developing is what comes out of the printer — no opening print preview after every change, and overflow is visible because content spills past the white edge.
CSS understands mm. Use it. Reasoning in millimetres against a known paper size is far easier than translating pixels.
Zero page margin, padding inside
Counter-intuitive, but for anything with a full-height sidebar:
@page {
size: A4 portrait;
margin: 0;
}
Then pad inside the sheet:
.side { width: 58mm; padding: 12mm 6mm 12mm 12mm; border-right: 0.75px solid #b9bec3; }
.main { flex: 1; padding: 12mm 12mm 12mm 7mm; }
With @page margins, the sidebar rule stops at the margin edge and floats in the middle of the page. With zero margin and internal padding, it runs the full height as intended.
Absolute units, always
The site scales type in rem. On the print page, everything is px:
.cv-shell { font-size: 9px; line-height: 1.34; }
.name { font-size: 23px; }
.job-dates{ font-size: 8.4px; }
rem resolves against the root font size, which is a global you do not control from a page. If anything changes it, your careful layout reflows and you find out when someone prints. Absolute units are immune.
Sub-pixel sizes like 8.4px are fine — print renders at much higher DPI than screen, so fractional sizes resolve cleanly.
Page breaks
Three properties, in order of usefulness:
.job { break-inside: avoid; page-break-inside: avoid; }
h2 { break-after: avoid; page-break-after: avoid; }
p, li { orphans: 2; widows: 2; }
break-inside: avoid on the right element is the whole game. Put it on a job entry and a job never splits. Put it on the section and you get the pagination disaster above. Scope it to the smallest unit that should stay together.
break-after: avoid on headings stops a heading stranded at the bottom of a page. orphans/widows prevent single lines separated from their paragraph.
Keep the page-break-* aliases alongside the modern properties. Support is good but not universal, and they cost nothing.
The dark-theme traps
Three that cost me time.
Global heading colours. The site sets h1 { color: #e8e8e3 } — near-white, correct for dark screens. My print page styled h1 size and weight but not colour, so the global won and the name printed nearly invisible on white. Any global element styling reaches your print page. Set colour explicitly on every heading.
Backgrounds do not print. Browsers skip background colours by default. A filled sidebar with light text becomes white on white:
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
This forces them, but the user can still override in the print dialog. Which is why I ended up with a hairline rule instead of a filled sidebar — a border always prints, a background is a request.
Theme rings and shadows. ring-4 ring-v3-bg puts a dark halo around an element, invisible on a dark background and an obvious grey box on paper. Strip decoration:
.cv-shell * { box-shadow: none !important; text-shadow: none !important; }
Guaranteeing one page
If it must be one page:
@media print {
.page { height: 296mm; overflow: hidden; }
}
296 rather than 297 guards against sub-pixel rounding pushing a blank second page.
Be clear about the trade: this clips, it does not shrink. Content past the limit silently disappears. I keep the screen version unclipped so overflow is visible while editing — the white sheet is the ruler.
Should you do this?
If a PDF is a real deliverable — a CV, an invoice, a report, a quote — yes. A dedicated page is less work than maintaining print overrides on a screen layout, and the result is better.
If you just want Ctrl+P to produce something acceptable, a modest print stylesheet on the screen page is fine. Hide navigation, force light colours, set sensible page breaks, stop.
The mistake is the middle: trying to make one page excellent at both. That is where the !important chains live.
Next, and last: loops, background jobs and scheduled agents — automating the work that runs while you are not watching.
Need a developer who ships fastwithout shipping mess?
I build and maintain WordPress, Laravel and Nuxt applications for businesses that care about performance and maintainability.