A teammate asks for a PDF export of the invoice page you already built. The client-side route (html2canvas plus jsPDF) hands back a fuzzy raster of the viewport with your CSS grid collapsed. The server-side route means adding Playwright to your image, a Chromium download, and enough memory headroom to run it.
SnapPDF runs the headless browser somewhere else and returns the bytes. It's a plain GET: no SDK, no auth headers on the direct base URL.
One request
curl -o page.pdf "https://snappdf.dedyn.io/v1/pdf?url=https://example.com"
If your target URL carries its own query string, encode it. Otherwise its params bleed into SnapPDF's:
curl -G "https://snappdf.dedyn.io/v1/pdf" \
--data-urlencode "url=https://example.com/invoice?id=8814&t=abc" \
--data-urlencode "format=Letter" \
-o invoice.pdf
Wiring it to a button
Node 18+ ships fetch globally. Proxy the call through your own server so you control the filename and never expose the target URL to the client:
// GET /invoices/:id/pdf
app.get("/invoices/:id/pdf", async (req, res) => {
const target = `https://app.example.com/invoices/${req.params.id}?print=1`;
const api = new URL("https://snappdf.dedyn.io/v1/pdf");
api.searchParams.set("url", target);
api.searchParams.set("format", "Letter");
api.searchParams.set("background", "true");
api.searchParams.set("wait_for_selector", "#invoice-total");
const r = await fetch(api);
if (!r.ok) {
console.error("snappdf failed", r.status, await r.text());
return res.status(502).json({ error: "pdf_failed" });
}
const pdf = Buffer.from(await r.arrayBuffer());
res.set("Content-Type", "application/pdf");
res.set("Content-Disposition", `attachment; filename="invoice-${req.params.id}.pdf"`);
res.send(pdf);
});
The front end becomes <a href="/invoices/8814/pdf">Download PDF</a>. No blob URLs, no client JS at all.
What comes back
The body is raw PDF bytes with Content-Type: application/pdf. No base64, no JSON wrapper on success, nothing to parse. Don't call r.json() on it.
Two behaviors to plan around:
The API sets Content-Disposition: inline, so a link straight to the endpoint opens the browser's PDF viewer instead of downloading. Overriding that header in your proxy (as above) is what turns it into a download.
Failures come back as JSON with a non-2xx status, so branch on r.ok before you trust the bytes. A URL you can't reach or can't parse returns an error status, not a broken PDF.
The params worth knowing
-
format—A4by default.A3,A5,Letter,Legal,Tabloidalso work. -
background—trueby default. Setfalseto drop background colors and images for a printer-friendly copy. -
landscape—trueorfalse. -
scale— a number. Shrinking to0.8pulls a wide table back onto one page. -
wait_for_selector— a CSS selector the renderer waits for before printing. This is the one that earns its keep on SPAs and charts: point it at an element that only exists after the data lands, and you stop shipping PDFs of your loading skeleton.
What I'd build with it
The setup that's worked for me: a print-only route plus a signed link.
Give the report page a ?print=1 mode that hides the nav, sidebar, and cookie banner, then widens the content to the page width. Mint a short-lived signed URL for that route and pass it as url=. The renderer arrives as an anonymous browser from outside your network, so it has no session cookie; the signature in the URL is how the page authorizes it.
Once that route exists, "email me a monthly statement" is a cron job with three steps: render, attach, send.
One constraint to design around: the renderer refuses localhost and private IP ranges, which is the SSRF guard doing its job. Your page has to be reachable from the public internet. In development, tunnel it with ngrok or cloudflared, or point at a deploy preview.
Examples and the OpenAPI spec live here: github.com/clause-netizen/snappdf-api
Top comments (0)