How to Generate a PDF from a React App

You need to add PDF export to your React app. Maybe it's an invoice, a report, or a user profile summary. Before you reach for jsPDF or html2canvas, read this. There's a better way that doesn't involve fighting browser rendering quirks.

The problem: client-side PDF generation is broken

The most common approach in React projects is to render HTML to a canvas using html2canvas, then convert that canvas to a PDF with jsPDF. It sounds reasonable. In practice, it's a mess.

Here's what goes wrong:

  • CSS support is incomplete. html2canvas doesn't support CSS grid, flexbox gaps, backdrop-filter, or many modern properties. Your carefully styled component renders as a broken layout in the PDF.
  • Fonts don't load consistently. Custom fonts often fail to render, falling back to system defaults. Your branded document looks generic.
  • Images cause CORS errors. Any cross-origin image breaks the canvas unless you proxy everything through your server.
  • Performance kills mobile. Rendering a complex DOM to canvas on a phone takes 5-10 seconds. Users think the app crashed.
  • Page breaks don't exist. jsPDF treats everything as one continuous image. Multi-page documents either overflow or get sliced at random points.

You end up spending more time fixing PDF rendering bugs than building actual features.

Why client-side is the wrong approach

PDFs are not screenshots. A screenshot captures pixels. A PDF is a structured document with text selection, page breaks, headers, footers, and metadata. Treating PDF generation as "take a screenshot and wrap it" fundamentally misses what a PDF needs to be.

The browser's built-in print engine (window.print / @media print CSS) gets closer, but you can't control the output format, file name, or delivery method. And users still have to click through the browser's print dialog.

The right approach: generate PDFs on the backend

Instead of rendering PDFs in the browser, move the generation to your backend. The flow becomes:

  1. User clicks "Download PDF" in your React app
  2. React calls your backend API endpoint
  3. Backend calls a PDF generation API with the right data
  4. Backend returns the PDF URL to the frontend
  5. React triggers a download

This approach gives you consistent output regardless of browser, device, or screen size. The PDF looks the same whether your user is on Chrome, Safari, or a phone.

Using Transactional.dev for the PDF generation

Transactional.dev lets you design PDF templates with HTML and Tailwind CSS, then generate them through a single API call. You design the template once in their editor, define variables for dynamic content, and call the API whenever you need a PDF.

Here's how to wire it up in a React + Node.js stack.

Backend: Express endpoint

// server.js
import express from 'express';

const app = express();
app.use(express.json());

app.post('/api/generate-report', async (req, res) => {
  const { userId, reportData } = req.body;

  try {
    const response = await fetch('https://api.transactional.dev/v1/generate', {
      method: 'POST',
      headers: {
        'x-api-token': process.env.TRANSACTIONAL_API_TOKEN,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        documentId: '1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
        variables: {
          user: { name: reportData.userName, email: reportData.userEmail },
          report: {
            title: reportData.title,
            generatedAt: new Date().toISOString(),
            sections: reportData.sections,
            summary: reportData.summary,
          },
        },
      }),
    });

    const { url } = await response.json();
    res.json({ pdfUrl: url });
  } catch (error) {
    console.error('PDF generation failed:', error);
    res.status(500).json({ error: 'Failed to generate PDF' });
  }
});

app.listen(3001);

Frontend: React component

// DownloadReport.jsx
import { useState } from 'react';

export function DownloadReport({ reportData }) {
  const [loading, setLoading] = useState(false);

  const handleDownload = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/generate-report', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ reportData }),
      });

      const { pdfUrl } = await response.json();

      // Trigger download
      const link = document.createElement('a');
      link.href = pdfUrl;
      link.download = `report-${Date.now()}.pdf`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    } catch (error) {
      console.error('Download failed:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleDownload} disabled={loading}>
      {loading ? 'Generating...' : 'Download PDF'}
    </button>
  );
}

Alternative: using Next.js API routes

If you're on Next.js, the backend endpoint lives in your API routes:

// app/api/generate-pdf/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { reportData } = await request.json();

  const response = await fetch('https://api.transactional.dev/v1/generate', {
    method: 'POST',
    headers: {
      'x-api-token': process.env.TRANSACTIONAL_API_TOKEN!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      documentId: '1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
      variables: {
        user: { name: reportData.userName },
        report: {
          title: reportData.title,
          sections: reportData.sections,
        },
      },
    }),
  });

  const { url } = await response.json();
  return NextResponse.json({ pdfUrl: url });
}

Testing with cURL

Before wiring up the frontend, verify your template works:

curl -X POST 'https://api.transactional.dev/v1/generate' \
  -H 'x-api-token: YOUR_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "documentId": "1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00",
    "variables": {
      "user": { "name": "Jane Developer" },
      "report": {
        "title": "Q2 Performance Report",
        "sections": [{"heading": "Revenue", "content": "$42,000"}]
      }
    }
  }'

Implementation notes

Keep your API token server-side. Never expose the Transactional.dev API token in your React bundle. Always proxy through your backend.

Add loading states. PDF generation takes 1-3 seconds depending on complexity. Show a spinner or disable the button so users don't double-click.

Handle errors gracefully. If the API is down or the template has issues, show a user-friendly error instead of a blank screen. Consider queuing a retry or offering an alternative format.

Cache when possible. If the same user requests the same report twice, consider caching the PDF URL for a few minutes instead of regenerating it.

Common mistakes

Calling the API from the browser. Your API token gets exposed in network requests. Always go through your backend.

Forgetting CORS on the PDF URL. The generated PDF URL needs to be accessible from the user's browser for download. Transactional.dev returns public URLs, so this isn't an issue. But if you're proxying the PDF through your server, make sure your CORS headers are correct.

Using html2canvas as a fallback. Some teams use the API for "important" PDFs and html2canvas for "quick" ones. This creates two rendering paths to maintain. Pick one approach and stick with it.

Not validating variables. If your React component sends undefined or null values as template variables, the PDF might render with blank fields. Validate before sending.

Conclusion

Client-side PDF generation in React is a trap. Libraries like jsPDF and html2canvas promise simplicity but deliver hours of debugging CSS rendering issues, font problems, and performance bottlenecks.

Move PDF generation to your backend and use an API like Transactional.dev. Design your template once with HTML and Tailwind, pass your variables, and get a production-ready PDF back. Your React component just needs a button and a fetch call.