HTML to PDF in Svelte and SvelteKit, measured

You have a Svelte component (an invoice, a report, a receipt) and want a "Download PDF" button. Most Svelte tutorials either run html2pdf.js in the browser or render the component on the server with render() from svelte/server. We built a fresh SvelteKit app, ran both on the same 40-row invoice, and found that the server route has a trap: by default, render() drops every scoped style, with no error or warning. Versions: Svelte 5.57.2, SvelteKit 3.1.0 (npx sv create, minimal template), Vite 8.3.5, html2pdf.js 0.14.0, Node 22, 21 October 2026. We opened every PDF with poppler.

ApproachWhat came out
html2pdf.js
html2canvas + jsPDF, in the browser
202 KB, 2 pages, the link kept, but 0 selectable characters: each page is a 719 px wide JPEG.
render() + Chromium API
default compiler options
25 KB, real text, but no styles at all: default serif font, no table borders, the header grid stacked into lines.
render() + Chromium API
with css="injected"
20.6 KB, 2 pages, 1,437 characters of text, the link kept, rows kept whole, "1/2" page numbers, 0.3 s.

The test component

An ordinary Svelte 5 component with runes and a scoped <style> block: a CSS grid header, a link and a 40-row table. The one line that matters for PDFs is <svelte:options css="injected" />; section 2 shows what happens without it.

<!-- Invoice.svelte: an ordinary component with scoped styles. -->
<svelte:options css="injected" />

<script>
	let { number, customer, items } = $props();
	const money = (n) => '$' + n.toFixed(2);
	let total = $derived(items.reduce((s, i) => s + i.qty * i.price, 0));
</script>

<div class="invoice">
	<header>
		<div><h1>Invoice {number}</h1><p>Billed to {customer}</p></div>
		<div>Acme Ltd<br /><a href="https://example.com/pay">Pay online</a></div>
	</header>
	<table>
		<thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Amount</th></tr></thead>
		<tbody>
			{#each items as i}
				<tr><td>{i.name}</td><td class="num">{i.qty}</td><td class="num">{money(i.qty * i.price)}</td></tr>
			{/each}
			<tr class="total"><td>Total</td><td></td><td class="num">{money(total)}</td></tr>
		</tbody>
	</table>
</div>

<style>
	.invoice { font-family: system-ui, sans-serif; color: #111; padding: 32px; }
	header { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; }
	h1 { margin: 0; font-size: 28px; }
	table { width: 100%; border-collapse: collapse; margin-top: 24px; }
	th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
	.num { text-align: right; }
	tr { break-inside: avoid; }
	.total { font-weight: 700; font-size: 18px; background: #f3f4f6; }
</style>

1. html2pdf.js in the browser: a picture of the invoice

html2pdf.js reads window as soon as it's imported, so in SvelteKit it has to be imported inside the click handler (a top-level import breaks server-side rendering of the page):

<script>
	import Invoice from '#lib/Invoice.svelte';

	const invoice = {
		number: 'INV-2048',
		customer: 'Jane Doe',
		items: Array.from({ length: 40 }, (_, i) => ({ name: `Consulting, week ${i + 1}`, qty: 1 + (i % 3), price: 120 }))
	};
	let target;

	async function save() {
		// html2pdf.js touches window on import, so load it in the browser only.
		const { default: html2pdf } = await import('html2pdf.js');
		html2pdf()
			.set({ margin: 10, filename: 'invoice.pdf', pagebreak: { mode: ['css'] } })
			.from(target)
			.save();
	}
</script>

<button onclick={save}>Download PDF</button>
<div bind:this={target}><Invoice {...invoice} /></div>

The download took 0.7 s and gave a 202,248-byte 2-page PDF. With pagebreak: { mode: ["css"] }, rows stayed whole and the "Pay online" link stayed clickable. The text, though, is an image: 0 selectable characters, and each page is a 719 px wide JPEG, so nobody can search or copy the invoice number, it looks soft when zoomed, and screen readers get nothing. That's the same result we got from html2pdf.js in Vue, since the library screenshots the DOM whatever the framework.

2. render() on the server: the styles silently vanish

The better route is to render the component to HTML on the server and let a real Chromium print it. Svelte has the function for it: render(Component, { props }) returns { head, body }. We called it in a SvelteKit endpoint and sent body to the PDF API, and the PDF came back with real, selectable text and no styling whatsoever: Chromium's default serif font, the grid header collapsed into stacked lines, no table borders, no right-aligned amounts.

The reason: in a Vite build, Svelte compiles each component's <style> into a separate CSS file that SvelteKit links from its own pages. render() only returns the markup with its svelte-xxxx scoped class names, so the CSS that matches them isn't in the string. Nothing in the dev server log mentions it.

The fix is one line at the top of the component, <svelte:options css="injected" />. It tells the compiler to emit the component's CSS with its markup, and render() then returns it as a <style> tag in head. Put head in the document's <head> and the scoped styles apply. You could set compilerOptions: { css: "injected" } in the sveltekit() plugin instead, but that changes every component in the app; the per-component option only affects what you print. (Child components need the option too if they have their own styles.)

3. The working SvelteKit endpoint

SvelteKit 3 only exposes environment variables that you declare, so declare the API key first. Without this file, $env/dynamic/private (now deprecated) gave us undefined and the API answered 401:

// src/env.js: SvelteKit 3 only exposes variables declared here.
import { defineEnvVars } from '@sveltejs/kit/env';

export const variables = defineEnvVars({
	SHOTMINT_KEY: { description: 'Shotmint API key (server only)' }
});

Then the endpoint. These are the exact files we ran, in dev and as a production build with @sveltejs/adapter-node (SHOTMINT_KEY=your_key node build):

// src/routes/invoice/[number]/+server.js
import { error } from '@sveltejs/kit';
import { render } from 'svelte/server';
import { SHOTMINT_KEY } from '$app/env/private';
import Invoice from '#lib/Invoice.svelte';

export async function GET({ params, fetch }) {
	// Load the real invoice from your database here.
	const data = {
		number: params.number,
		customer: 'Jane Doe',
		items: Array.from({ length: 40 }, (_, i) => ({ name: `Consulting, week ${i + 1}`, qty: 1 + (i % 3), price: 120 }))
	};
	// head holds the component's <style> (from css="injected"), body the markup.
	const { head, body } = render(Invoice, { props: data });
	const html = `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${body}</body></html>`;

	const r = await fetch('https://shotmint.creasework.com/v1/pdf', {
		method: 'POST',
		headers: { 'X-API-Key': SHOTMINT_KEY, 'Content-Type': 'application/json' },
		body: JSON.stringify({ html, format: 'A4', margin: '12mm', page_numbers: true })
	});
	if (!r.ok) error(502, await r.text());

	return new Response(await r.arrayBuffer(), {
		headers: {
			'Content-Type': 'application/pdf',
			'Content-Disposition': `attachment; filename="invoice-${params.number}.pdf"`
		}
	});
}

On SvelteKit 2, drop src/env.js and read the key with import { env } from "$env/dynamic/private" and env.SHOTMINT_KEY, and import the component from $lib/Invoice.svelte (new SvelteKit 3 projects use the #lib subpath import from package.json); the rest is the same. A link is all the page needs. SvelteKit's router leaves links with a download attribute alone, so the browser fetches the endpoint directly:

<!-- The browser only asks your server for the file; the API key never reaches the client. -->
<script>
	let { number = 'INV-2048' } = $props();
</script>

<a href="/invoice/{number}" download>Download PDF</a>

Result on the same invoice: a 20,568-byte A4 PDF with 2 pages, 1,437 characters of selectable text (pdftotext), the "Pay online" link clickable, the page break falling between weeks 23 and 24 (no sliced row), and "1/2" and "2/2" at the bottom of each page from page_numbers. Requests took 0.24 to 0.39 s in dev and 0.30 s on the production build. Clicking the link in headless Chromium downloaded invoice-INV-2048.pdf without leaving the page.

Which one to use

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription. Get a free key to try the endpoint above.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons