Tutorial

PDF Generation in Next.js: The Complete Guide

|12 min read

Generating PDFs in Next.js means choosing between heavy browser libraries, limited pure-JS solutions, or a simple API call. This guide covers the practical approaches: URL-to-PDF, HTML-to-PDF with templates, Server Actions, and Edge Runtime compatibility.

What you will learn

1.URL-to-PDF in API Routes
2.HTML-to-PDF with templates
3.Invoice PDF generation
4.Server Actions for PDFs
5.Edge Runtime support
6.Client-side download flow
7.Styling and layout control
8.Production deployment

1. URL-to-PDF in API Routes

The simplest approach: send a URL to SnapRender and get a PDF back. Works for any publicly accessible page:

app/api/pdf/route.js
// app/api/pdf/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  const { url } = await request.json();

  // Generate PDF from any URL via SnapRender
  const response = await fetch(
    'https://api.snaprender.dev/v1/pdf',
    {
      method: 'POST',
      headers: {
        'x-api-key': process.env.SNAPRENDER_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        url,
        format: 'A4',
        margin: {
          top: '20mm',
          bottom: '20mm',
          left: '15mm',
          right: '15mm',
        },
      }),
    }
  );

  const pdfBuffer = await response.arrayBuffer();

  return new NextResponse(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="document.pdf"',
    },
  });
}

2. HTML-to-PDF: Invoice generation

For dynamic documents like invoices, build HTML from your data and convert it to PDF:

app/api/invoice/route.js
// app/api/invoice/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  const { invoiceId, items, customer } = await request.json();

  const total = items.reduce((sum, i) => sum + i.qty * i.price, 0);

  // Build HTML template with inline styles
  const html = `
    <!DOCTYPE html>
    <html>
    <head>
      <style>
        body { font-family: 'Helvetica', sans-serif; padding: 40px; }
        .header { display: flex; justify-content: space-between; }
        .company { font-size: 24px; font-weight: bold; }
        .invoice-id { color: #666; }
        table { width: 100%; border-collapse: collapse; margin-top: 30px; }
        th { background: #f5f5f5; text-align: left; padding: 10px; }
        td { padding: 10px; border-bottom: 1px solid #eee; }
        .total { font-size: 20px; text-align: right; margin-top: 20px; }
      </style>
    </head>
    <body>
      <div class="header">
        <div class="company">Your Company</div>
        <div class="invoice-id">Invoice #${invoiceId}</div>
      </div>
      <p>Bill to: ${customer.name}</p>
      <table>
        <thead>
          <tr><th>Item</th><th>Qty</th><th>Price</th><th>Total</th></tr>
        </thead>
        <tbody>
          ${items.map(i => `
            <tr>
              <td>${i.name}</td>
              <td>${i.qty}</td>
              <td>$${i.price.toFixed(2)}</td>
              <td>$${(i.qty * i.price).toFixed(2)}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
      <div class="total">Total: $${total.toFixed(2)}</div>
    </body>
    </html>
  `;

  const response = await fetch(
    'https://api.snaprender.dev/v1/pdf',
    {
      method: 'POST',
      headers: {
        'x-api-key': process.env.SNAPRENDER_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        html,
        format: 'A4',
        margin: { top: '10mm', bottom: '10mm' },
        print_background: true,
      }),
    }
  );

  const pdfBuffer = await response.arrayBuffer();

  return new NextResponse(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition':
        `attachment; filename="invoice-${invoiceId}.pdf"`,
    },
  });
}

Pro tip

Use inline CSS in your HTML templates. External stylesheets need to be publicly accessible or inlined at build time. SnapRender renders exactly what you send.

3. Server Actions

Next.js Server Actions let you call server-side code directly from React components:

app/actions/generate-pdf.js
// app/actions/generate-pdf.js
'use server';

export async function generateReport(reportData) {
  const html = buildReportHtml(reportData);

  const response = await fetch(
    'https://api.snaprender.dev/v1/pdf',
    {
      method: 'POST',
      headers: {
        'x-api-key': process.env.SNAPRENDER_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        html,
        format: 'A4',
        landscape: true,
        print_background: true,
      }),
    }
  );

  const buffer = await response.arrayBuffer();
  return Buffer.from(buffer).toString('base64');
}

function buildReportHtml(data) {
  return `<!DOCTYPE html>
  <html>
    <head><style>
      body { font-family: system-ui; padding: 40px; }
      h1 { color: #1a1a1a; }
      .metric { display: inline-block; padding: 20px; margin: 10px;
                background: #f8f8f8; border-radius: 8px; }
      .metric .value { font-size: 32px; font-weight: bold; }
      .metric .label { color: #666; font-size: 14px; }
    </style></head>
    <body>
      <h1>${data.title}</h1>
      <p>Generated: ${new Date().toLocaleDateString()}</p>
      <div>
        ${data.metrics.map(m => `
          <div class="metric">
            <div class="value">${m.value}</div>
            <div class="label">${m.label}</div>
          </div>
        `).join('')}
      </div>
    </body>
  </html>`;
}

4. Client-side download button

Wire up the API route to a download button in your React component:

app/components/DownloadButton.jsx
// app/components/DownloadButton.jsx
'use client';

import { useState } from 'react';

export default function DownloadPdfButton({ invoiceId }) {
  const [loading, setLoading] = useState(false);

  async function handleDownload() {
    setLoading(true);

    try {
      const response = await fetch('/api/invoice', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          invoiceId,
          customer: { name: 'Acme Corp' },
          items: [
            { name: 'API Credits', qty: 1000, price: 0.01 },
            { name: 'Premium Support', qty: 1, price: 99 },
          ],
        }),
      });

      const blob = await response.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `invoice-${invoiceId}.pdf`;
      a.click();
      URL.revokeObjectURL(url);
    } catch (error) {
      console.error('PDF generation failed:', error);
    } finally {
      setLoading(false);
    }
  }

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

5. Edge Runtime support

Because SnapRender is just a fetch call, it works on Edge Runtime with zero Node.js dependencies:

app/api/pdf-edge/route.js
// app/api/pdf-edge/route.js
export const runtime = 'edge';

export async function POST(request) {
  const { url } = await request.json();

  // Works on Edge - just a fetch call, no Node.js APIs needed
  const response = await fetch(
    'https://api.snaprender.dev/v1/pdf',
    {
      method: 'POST',
      headers: {
        'x-api-key': process.env.SNAPRENDER_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ url, format: 'A4' }),
    }
  );

  return new Response(response.body, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="document.pdf"',
    },
  });
}

Comparison: PDF approaches in Next.js

ApproachBest forLimitation
PuppeteerFull control, self-hosted300MB binary, no serverless
jsPDFSimple text PDFsNo CSS/HTML rendering
react-pdfCustom layoutsOwn component system, not HTML
SnapRender APIHTML/URL to PDF, any runtimeAPI cost at high volume

Generate PDFs from any Next.js app

SnapRender handles the browser rendering. Send HTML or a URL, get a pixel-perfect PDF back. Works on Node.js, Edge, and serverless.

Get Your API Key — Free

Frequently asked questions

Next.js itself does not have PDF generation built in. You need either a library (Puppeteer, jsPDF, react-pdf) or an API like SnapRender. API Routes and Server Actions are the ideal place to generate PDFs because they run on the server with no browser restrictions.

Puppeteer works but requires a Chromium binary (300MB+), makes serverless deployments difficult (Lambda has a 250MB limit), and is slow on cold starts. An API like SnapRender handles the browser infrastructure for you, works from any hosting, and returns PDFs in 2-5 seconds.

Render your React component to HTML (using ReactDOMServer.renderToString), include your CSS (inline or as a style tag), then send the HTML to SnapRender's PDF endpoint. This lets you use the same components and styles from your app in your PDFs.

Edge Runtime does not support Puppeteer or native Node.js modules. However, you can call the SnapRender API from Edge functions since it is just a fetch() call. This gives you PDF generation at the edge with sub-100ms response times.

Include header/footer HTML in your template, or use CSS @page rules for margins and running headers. SnapRender supports custom header and footer HTML, margin configuration, and CSS @page rules for print-specific styling.