DEV Community

clause-netizen
clause-netizen

Posted on

Add a "Download as PDF" Button to Any Web App With One GET Request

Someone on your team asks for a "Download as PDF" button on the invoice page. You look at the options: puppeteer adds a ~300MB Chromium download to your deploy, wkhtmltopdf is abandoned, and the client-side libraries choke on your CSS. All you want is the page you already built, as a PDF.

SnapPDF does that one thing. You give it a URL, it renders the page in a real headless browser, and it returns the PDF bytes. No API key, no JSON envelope, no SDK.

The whole API

GET https://snappdf.dedyn.io/v1/pdf?url=https://EXAMPLE.com
Enter fullscreen mode Exit fullscreen mode

Try it from your terminal:

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

Open example.pdf. That's the entire integration.

The response is the raw PDF (Content-Type: application/pdf), not JSON with a base64 blob or a link you have to fetch in a second round trip. Whatever bytes come back, you can write straight to disk or stream straight to a browser.

A few query params when the defaults don't fit:

Param What it does
format Paper size, A4 by default
landscape true or false
background true or false, render CSS backgrounds
scale Zoom the render, e.g. 0.8 to fit wide tables
wait_for_selector CSS selector to wait for before capture

wait_for_selector is the one that saves you on client-rendered apps. If your invoice page is React and the line items arrive after an API call, pass wait_for_selector=.invoice-total and the capture waits until that element exists instead of snapshotting a spinner.

The download button

Here's the pattern I actually use: an Express route that proxies the PDF, so the browser gets a file download instead of navigating away. Node 18+ has fetch built in, so there are zero dependencies.

app.get("/invoices/:id/pdf", async (req, res) => {
  const pageUrl = `https://yourapp.com/invoices/${req.params.id}`;

  const upstream = await fetch(
    "https://snappdf.dedyn.io/v1/pdf?" +
      new URLSearchParams({
        url: pageUrl,
        wait_for_selector: ".invoice-total",
        background: "true",
      })
  );

  if (!upstream.ok) {
    return res.status(502).send("PDF render failed");
  }

  res.set("Content-Type", "application/pdf");
  res.set(
    "Content-Disposition",
    `attachment; filename="invoice-${req.params.id}.pdf"`
  );
  res.send(Buffer.from(await upstream.arrayBuffer()));
});
Enter fullscreen mode Exit fullscreen mode

The frontend part is an anchor tag:

<a href="/invoices/1042/pdf">Download as PDF</a>
Enter fullscreen mode Exit fullscreen mode

The Content-Disposition: attachment header is what turns a navigation into a download. Skip it and the PDF opens in a tab, which is sometimes what you want for a "Print view" link instead.

Two things worth knowing before you ship this:

  • The renderer visits the URL like an anonymous browser. If your invoice page sits behind a login, the renderer sees your login screen. Expose the printable view on a tokenized URL (/invoices/1042/print?token=...) that your proxy route generates.
  • Renders take a couple of seconds since a real browser loads your page. For a button click that's fine. If you're generating hundreds of monthly statements, run it in a queue and cache the output.

Beyond invoices, the same proxy route handles receipts, shipping labels, certificates, and reports. Anything you can build as an HTML page, you can hand to users as a PDF, and HTML plus CSS is a far better layout engine than any PDF library's rectangle-drawing API.

Examples in more languages live in the repo: github.com/clause-netizen/snappdf-api.

Top comments (0)