DEV Community

clause-netizen
clause-netizen

Posted on

Render invoices to PDF with a GET request instead of shipping Chromium

Every "generate a PDF invoice" ticket ends the same way: you already have the invoice as an HTML template that looks right in the browser, and now you need bytes you can email or store. The usual answer is Puppeteer, which drags a ~300MB Chromium download into your image, needs a pile of shared libraries on slim base images, and falls over on the first serverless deploy that has a 250MB bundle limit. The alternative is rewriting your invoice layout in a PDF drawing library, which means maintaining the same document twice.

A hosted renderer sidesteps both. You keep the HTML template, and the PDF is one HTTP GET.

One request

SnapPDF takes a public URL and returns PDF bytes:

curl -o invoice.pdf \
  "https://snappdf.dedyn.io/v1/pdf?url=https://example.com"
Enter fullscreen mode Exit fullscreen mode

No headers, no auth dance on the direct base URL, no JSON envelope to unwrap. The response body is the PDF.

Query params, all optional:

Param Values Default
format A4, Letter, etc. A4
landscape true / false false
background true / false true
scale number 1
wait_for_selector CSS selector none

background matters more than it sounds for invoices. Chromium drops CSS backgrounds when printing by default, so your header bar and zebra-striped line items vanish unless it's on.

wait_for_selector is the one that saves you. If your invoice template fetches line items client-side, the renderer will otherwise happily print an empty table. Have the template set something like <div id="invoice-ready"> once totals are painted, and point the param at #invoice-ready.

curl -o invoice.pdf \
  "https://snappdf.dedyn.io/v1/pdf?url=https%3A%2F%2Fexample.com&format=Letter&background=true&wait_for_selector=%23invoice-ready"
Enter fullscreen mode Exit fullscreen mode

Node, with global fetch

Node 18+ has fetch built in, so there's no dependency at all:

import { writeFile } from "node:fs/promises";

async function renderPdf(targetUrl, opts = {}) {
  const qs = new URLSearchParams({ url: targetUrl, ...opts });
  const res = await fetch(`https://snappdf.dedyn.io/v1/pdf?${qs}`);

  if (!res.ok) {
    throw new Error(`SnapPDF ${res.status}: ${await res.text()}`);
  }
  const type = res.headers.get("content-type") || "";
  if (!type.includes("application/pdf")) {
    throw new Error(`Expected a PDF, got ${type}`);
  }
  return Buffer.from(await res.arrayBuffer());
}

const pdf = await renderPdf("https://example.com", {
  format: "Letter",
  background: "true",
});
await writeFile("invoice.pdf", pdf);
Enter fullscreen mode Exit fullscreen mode

URLSearchParams handles the encoding, which you want — invoice URLs usually carry a token, and an unencoded & in there silently truncates the target.

The content-type check isn't paranoia. Any failure path that returns a JSON error will otherwise get written to disk as invoice.pdf, and you'll find out when a customer opens it.

What you actually get back

A 200 with Content-Type: application/pdf and the raw file in the body. Nothing to parse, nothing to base64-decode. Treat it as a Buffer (or a stream) and hand it to whatever comes next: S3, a Nodemailer attachment, res.send().

The one constraint worth planning around: the renderer fetches the URL over the public internet, so localhost:3000/invoices/1042 won't work and neither will a page behind your session cookie. The pattern that does work is a signed, short-lived, unauthenticated route.

The thing I'd build with it

An /invoices/:id/pdf endpoint that reuses the template you already have:

import express from "express";
import crypto from "node:crypto";

const app = express();

// Public, signed, no session required — this is what the renderer loads.
app.get("/render/invoice/:id", (req, res) => {
  if (!validSignature(req.params.id, req.query.sig)) return res.sendStatus(403);
  res.send(renderInvoiceHtml(req.params.id)); // your existing template
});

app.get("/invoices/:id/pdf", requireAuth, async (req, res) => {
  const { id } = req.params;
  const sig = crypto
    .createHmac("sha256", process.env.RENDER_SECRET)
    .update(id)
    .digest("hex");

  const target = `https://yourapp.com/render/invoice/${id}?sig=${sig}`;
  const pdf = await renderPdf(target, {
    format: "Letter",
    background: "true",
    wait_for_selector: "#invoice-ready",
  });

  res.type("application/pdf")
     .set("Content-Disposition", `attachment; filename="invoice-${id}.pdf"`)
     .send(pdf);
});
Enter fullscreen mode Exit fullscreen mode

Two routes, one template, zero browser binaries. The HTML invoice stays the source of truth — when finance asks for the tax line to move, you edit CSS and both the web view and the PDF change together.

Same shape works for packing slips, signed quotes, and monthly reports. Anything where you already built the HTML and someone downstream wants a file.

Working examples and the full param list: github.com/clause-netizen/snappdf-api

Top comments (2)

Collapse
 
topstar_ai profile image
Luis Cruz

I particularly like how SnapPDF handles the background parameter, which is often overlooked when printing invoices, and the wait_for_selector parameter is a clever solution for handling dynamically loaded content. The fact that it can be used with a simple GET request makes it a lightweight alternative to Puppeteer. Have you considered any caching mechanisms to reduce the load on the SnapPDF service, especially if you're generating a large number of invoices?

Collapse
 
to21as profile image
Tobias

The wait_for_selector advice is the right shape, and for invoices there's a second gate it doesn't cover: web fonts. A selector can be painted while the font is still swapping, and fallback metrics are almost never the same width, so the totals column reflows and the page breaks move with it. On a one-page invoice you get away with it. On a three-page statement the last line item lands on page 4. document.fonts.ready is the thing to await, and if the renderer only gives you a selector hook, set the ready flag inside document.fonts.ready.then() rather than right after the data render, and you cover both with the one param.

The other one worth deciding on purpose before this goes to production: the response to a GET is cacheable by anything in the path. A signed short-lived URL keeps the page private, but the PDF now comes back as a GET body through whatever proxy sits between you and the renderer, and the target URL with its token lands in access logs at both ends. Not a reason to avoid the design, it just wants an explicit Cache-Control: no-store rather than an accidental one.