How to Build a "Download as PDF" Button in Your SaaS

Users expect a "Download as PDF" button. Whether it's an invoice, a report, or a contract — they want a clean PDF file, not a browser print dialog. Getting this right without creating a maintenance nightmare takes a bit of planning.

The Naive Approach and Why It Breaks

The first thing most developers reach for is a client-side library like jsPDF or html2canvas. You call jsPDF.save() directly in the browser, and it seems to work — until it doesn't.

The problems pile up fast:

  • Layout inconsistency. Your CSS renders differently in jsPDF than in the browser. Fonts go missing, flexbox collapses, images don't load.
  • No server-side data. If your PDF needs fresh data from the database (totals, signatures, account details), you either over-fetch it to the client or accept stale content.
  • You can't sign or track. There's no audit trail, no signed URL, no way to track who downloaded what.
  • iframe hacks are worse. Printing a hidden iframe works on no two browsers the same way.

These approaches all share the same root problem: PDF generation belongs on the backend, not in the browser.

The Right Architecture

The correct pattern is straightforward:

  1. The user clicks "Download as PDF"
  2. The frontend sends a request to your backend endpoint
  3. Your backend calls a PDF generation API with the document template and data
  4. The API returns a signed URL pointing to the generated PDF
  5. Your backend returns that URL to the frontend
  6. The frontend opens or downloads the file

Your API token never touches the client. The PDF is always generated server-side with fresh data. The signed URL is short-lived, so you don't have to manage file storage yourself.

Where Transactional.dev Fits In

Transactional.dev is a PDF generation API built for exactly this pattern. You define reusable document templates using HTML and Tailwind CSS, then generate PDFs by POSTing your data to the API. It handles rendering, file hosting, and signed URL generation.

You get a production-ready PDF from a single API call, with no Puppeteer to maintain and no S3 bucket to configure.

Step-by-Step Implementation

Step 1 — Create your template in Transactional.dev

Log in to Transactional.dev and create a document. Design your template using HTML and Tailwind variables. You'll get a document UUID that you'll use in API calls.

For example, an invoice template might accept variables like { "invoiceNumber": "INV-001", "total": "150.00", "clientName": "Acme Corp" }.

Step 2 — Build the backend endpoint

Your backend exposes a POST /api/export-pdf endpoint. It receives the document context from the frontend, calls the Transactional.dev API, and returns the signed URL.

// routes/export-pdf.js (Express)
const express = require('express');
const router = express.Router();

const TRANSACTIONAL_API_URL = 'https://api.transactional.dev/v1/generate';
const TRANSACTIONAL_API_TOKEN = process.env.TRANSACTIONAL_API_TOKEN;
const DOCUMENT_ID = process.env.INVOICE_DOCUMENT_ID; // your template UUID

router.post('/api/export-pdf', async (req, res) => {
  const { invoiceId } = req.body;

  // Fetch the data you need for the PDF
  const invoice = await Invoice.findById(invoiceId);
  if (!invoice) {
    return res.status(404).json({ error: 'Invoice not found' });
  }

  // Call the Transactional.dev API
  const response = await fetch(TRANSACTIONAL_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-token': TRANSACTIONAL_API_TOKEN,
    },
    body: JSON.stringify({
      documentId: DOCUMENT_ID,
      variables: {
        invoiceNumber: invoice.number,
        clientName: invoice.clientName,
        total: invoice.total.toFixed(2),
        issueDate: invoice.issueDate.toISOString().slice(0, 10),
        lineItems: invoice.lineItems,
      },
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    return res.status(502).json({ error: 'PDF generation failed', detail: error });
  }

  const data = await response.json();
  // data.url is a signed, short-lived URL pointing to the generated PDF

  return res.json({ url: data.url });
});

module.exports = router;

The API returns { "url": "https://files.transactional.dev/...", "documentId": "..." }. The URL is signed and short-lived — redirect to it or return it to the frontend immediately.

Step 3 — Wire the frontend button

On the frontend, the button triggers a fetch to your backend endpoint, then opens or downloads the returned URL.

// React example
async function handleDownloadPDF(invoiceId) {
  const button = document.getElementById('download-btn');
  button.disabled = true;
  button.textContent = 'Generating...';

  try {
    const res = await fetch('/api/export-pdf', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ invoiceId }),
    });

    if (!res.ok) throw new Error('Failed to generate PDF');

    const { url } = await res.json();

    // Option A: open in new tab
    window.open(url, '_blank');

    // Option B: trigger download with a filename
    // const a = document.createElement('a');
    // a.href = url;
    // a.download = `invoice-${invoiceId}.pdf`;
    // a.click();
  } catch (err) {
    alert('Could not generate PDF. Please try again.');
    console.error(err);
  } finally {
    button.disabled = false;
    button.textContent = 'Download PDF';
  }
}

Use window.open(url, '_blank') for simplicity, or create an anchor element with the download attribute if you want to force a specific filename. Both work fine with a signed URL.

Tradeoffs and Common Mistakes

Do not stream binary PDF content through your backend to the frontend. Some tutorials show piping the PDF binary through your server. This adds latency, burns bandwidth on your server, and is unnecessary when you have a signed URL. Return the URL and let the browser fetch directly from the CDN.

Never expose your API token client-side. The API call must happen on your backend. If you put the token in your frontend JavaScript, it's public. Anyone can find it and use your quota.

Handle the short-lived URL correctly. The signed URL from Transactional.dev is valid for a limited time window (typically a few minutes). Two patterns work well:

  • Return the URL immediately and open it in the same request cycle (recommended)
  • Persist the PDF to your own storage if you need a permanent link

If you cache the URL for later use and it expires, you'll get a 403. Either re-generate on demand or save the file immediately after generation.

Disable the button during generation. PDF generation takes a second or two. Without visual feedback, users will click multiple times and generate duplicate PDFs.

Check for errors from the API. A non-2xx response from Transactional.dev usually means a template rendering error or invalid variables. Log the full error response on the backend so you can debug template issues without guessing.

Conclusion

The pattern is simple: backend endpoint, API call, signed URL, download. Keep the API token on the server, return the URL to the client, and handle edge cases like errors and loading states.

You can set this up in a few minutes with Transactional.dev's free tier. Create a template, grab your document ID, and you're one API call away from a working PDF export.