DEV Community

clause-netizen
clause-netizen

Posted on

Add a "Download as PDF" button with one GET request

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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

  • formatA4 by default. A3, A5, Letter, Legal, Tabloid also work.
  • backgroundtrue by default. Set false to drop background colors and images for a printer-friendly copy.
  • landscapetrue or false.
  • scale — a number. Shrinking to 0.8 pulls 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)