React-PDF + Next.js 15: Generate Professional PDFs, Invoices, and Reports with TypeScript

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

Generate pixel-perfect PDFs with React components. @react-pdf/renderer lets you build PDFs using familiar JSX syntax — no headless browsers, no HTML-to-PDF hacks. In this tutorial, you will build a complete invoice generation system with Next.js 15, Server Actions, and downloadable PDF documents.

What You Will Learn

By the end of this tutorial, you will:

  • Set up @react-pdf/renderer in a Next.js 15 project with TypeScript
  • Build reusable PDF components using React-PDF primitives (Document, Page, View, Text)
  • Create a professional invoice template with tables, headers, and footers
  • Generate PDFs server-side using Next.js API routes and Server Actions
  • Add dynamic data from forms to produce customized documents
  • Implement a download endpoint that streams PDFs to the browser
  • Support custom fonts and RTL languages (Arabic) in your documents
  • Deploy a production-ready PDF generation pipeline

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, interfaces, async/await)
  • Next.js familiarity (App Router, Server Components, API Routes)
  • React knowledge (components, props, JSX)
  • A code editor — VS Code or Cursor recommended

Why @react-pdf/renderer?

There are several approaches to PDF generation in JavaScript. Here is how they compare:

ApproachServerless-ReadyLayout ControlBundle SizeComplexity
@react-pdf/rendererYesFull (Flexbox)~500 KBLow
PuppeteerNo (needs Chrome)Full (HTML/CSS)~300 MBHigh
jsPDFYesManual coordinates~300 KBMedium
PDFKitYesManual coordinates~1 MBMedium
html-pdfNo (deprecated)HTML/CSS subsetVariesMedium

@react-pdf/renderer is the clear winner for React/Next.js projects because:

  1. You already know the API — it is JSX with React components
  2. No headless browser — generates PDFs natively, works in serverless
  3. Flexbox layout — position elements naturally, not with x/y coordinates
  4. Streaming support — generate and stream large PDFs efficiently
  5. Custom fonts — register any TTF/OTF font, including Arabic typefaces

Step 1: Project Setup

Create a new Next.js 15 project with TypeScript:

npx create-next-app@latest pdf-invoice-app --typescript --tailwind --app --src-dir
cd pdf-invoice-app

Install the required dependencies:

npm install @react-pdf/renderer

That is the only dependency you need for PDF generation. The library provides everything: document structure, styling, fonts, and rendering.

Your project structure will look like this:

pdf-invoice-app/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── invoice/
│   │   │       └── route.ts          # PDF generation endpoint
│   │   ├── invoice/
│   │   │   └── page.tsx              # Invoice form page
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── components/
│   │   └── pdf/
│   │       ├── InvoiceDocument.tsx    # Main PDF document
│   │       ├── InvoiceHeader.tsx      # Header component
│   │       ├── InvoiceTable.tsx       # Line items table
│   │       ├── InvoiceFooter.tsx      # Footer with totals
│   │       └── styles.ts             # Shared PDF styles
│   └── lib/
│       └── types.ts                  # Invoice TypeScript types
├── public/
│   └── fonts/                        # Custom fonts (optional)
├── package.json
└── tsconfig.json

Step 2: Define Invoice Types

Start by defining the TypeScript types for your invoice data. Create the types file:

// src/lib/types.ts
 
export interface InvoiceItem {
  description: string;
  quantity: number;
  unitPrice: number;
  total: number;
}
 
export interface InvoiceData {
  invoiceNumber: string;
  date: string;
  dueDate: string;
  // Sender
  company: {
    name: string;
    address: string;
    city: string;
    country: string;
    email: string;
    phone: string;
    taxId?: string;
  };
  // Recipient
  client: {
    name: string;
    address: string;
    city: string;
    country: string;
    email: string;
  };
  // Line items
  items: InvoiceItem[];
  // Financials
  subtotal: number;
  taxRate: number;
  taxAmount: number;
  total: number;
  // Optional
  notes?: string;
  currency: string;
}

These types will be shared between the form, the API route, and the PDF components — giving you end-to-end type safety.


Step 3: Create PDF Styles

React-PDF uses a StyleSheet API similar to React Native. Define your shared styles:

// src/components/pdf/styles.ts
 
import { StyleSheet } from "@react-pdf/renderer";
 
export const colors = {
  primary: "#1a1a2e",
  secondary: "#16213e",
  accent: "#0f3460",
  text: "#333333",
  lightText: "#666666",
  border: "#e0e0e0",
  background: "#f8f9fa",
  white: "#ffffff",
};
 
export const styles = StyleSheet.create({
  page: {
    padding: 40,
    fontSize: 10,
    fontFamily: "Helvetica",
    color: colors.text,
    backgroundColor: colors.white,
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 30,
    paddingBottom: 20,
    borderBottomWidth: 2,
    borderBottomColor: colors.primary,
  },
  companyName: {
    fontSize: 22,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 4,
  },
  invoiceTitle: {
    fontSize: 28,
    fontFamily: "Helvetica-Bold",
    color: colors.accent,
    textAlign: "right",
  },
  invoiceMeta: {
    textAlign: "right",
    fontSize: 10,
    color: colors.lightText,
    marginTop: 4,
  },
  section: {
    marginBottom: 20,
  },
  sectionTitle: {
    fontSize: 11,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 6,
    textTransform: "uppercase",
    letterSpacing: 1,
  },
  row: {
    flexDirection: "row",
  },
  col: {
    flex: 1,
  },
  // Table styles
  table: {
    marginTop: 10,
    marginBottom: 20,
  },
  tableHeader: {
    flexDirection: "row",
    backgroundColor: colors.primary,
    padding: 8,
    borderRadius: 4,
  },
  tableHeaderCell: {
    color: colors.white,
    fontSize: 9,
    fontFamily: "Helvetica-Bold",
    textTransform: "uppercase",
    letterSpacing: 0.5,
  },
  tableRow: {
    flexDirection: "row",
    padding: 8,
    borderBottomWidth: 1,
    borderBottomColor: colors.border,
  },
  tableRowAlt: {
    flexDirection: "row",
    padding: 8,
    backgroundColor: colors.background,
    borderBottomWidth: 1,
    borderBottomColor: colors.border,
  },
  tableCell: {
    fontSize: 10,
    color: colors.text,
  },
  descriptionCol: { width: "45%" },
  quantityCol: { width: "15%", textAlign: "center" },
  priceCol: { width: "20%", textAlign: "right" },
  totalCol: { width: "20%", textAlign: "right" },
  // Totals
  totalsContainer: {
    flexDirection: "row",
    justifyContent: "flex-end",
    marginTop: 10,
  },
  totalsBox: {
    width: 250,
    padding: 12,
    backgroundColor: colors.background,
    borderRadius: 4,
  },
  totalsRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 6,
  },
  totalsFinal: {
    flexDirection: "row",
    justifyContent: "space-between",
    paddingTop: 8,
    borderTopWidth: 2,
    borderTopColor: colors.primary,
    marginTop: 4,
  },
  totalsFinalText: {
    fontSize: 14,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
  },
  // Footer
  footer: {
    position: "absolute",
    bottom: 30,
    left: 40,
    right: 40,
    textAlign: "center",
    fontSize: 8,
    color: colors.lightText,
    borderTopWidth: 1,
    borderTopColor: colors.border,
    paddingTop: 10,
  },
  notes: {
    marginTop: 20,
    padding: 12,
    backgroundColor: colors.background,
    borderRadius: 4,
    borderLeftWidth: 3,
    borderLeftColor: colors.accent,
  },
  notesTitle: {
    fontSize: 10,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 4,
  },
  notesText: {
    fontSize: 9,
    color: colors.lightText,
    lineHeight: 1.5,
  },
});

Key differences from regular CSS:

  • No shorthand properties — use paddingTop instead of padding: "10 0"
  • Flexbox is default — every View is a flex container
  • Units are points — 1pt = 1/72 inch (standard PDF unit)
  • Limited properties — only a subset of CSS is supported (no grid, no float)

Step 4: Build the Invoice Header

Create the header component that shows company info and invoice metadata:

// src/components/pdf/InvoiceHeader.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceHeaderProps {
  data: InvoiceData;
}
 
export function InvoiceHeader({ data }: InvoiceHeaderProps) {
  return (
    <View style={styles.header}>
      {/* Left: Company Info */}
      <View style={styles.col}>
        <Text style={styles.companyName}>{data.company.name}</Text>
        <Text>{data.company.address}</Text>
        <Text>
          {data.company.city}, {data.company.country}
        </Text>
        <Text>{data.company.email}</Text>
        <Text>{data.company.phone}</Text>
        {data.company.taxId && (
          <Text style={{ marginTop: 4, fontSize: 9 }}>
            Tax ID: {data.company.taxId}
          </Text>
        )}
      </View>
 
      {/* Right: Invoice Meta */}
      <View>
        <Text style={styles.invoiceTitle}>INVOICE</Text>
        <Text style={styles.invoiceMeta}>#{data.invoiceNumber}</Text>
        <Text style={styles.invoiceMeta}>Date: {data.date}</Text>
        <Text style={styles.invoiceMeta}>Due: {data.dueDate}</Text>
      </View>
    </View>
  );
}

Step 5: Build the Line Items Table

The table component renders each invoice item with alternating row colors:

// src/components/pdf/InvoiceTable.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceItem } from "@/lib/types";
 
interface InvoiceTableProps {
  items: InvoiceItem[];
  currency: string;
}
 
function formatCurrency(amount: number, currency: string): string {
  return `${currency} ${amount.toFixed(2)}`;
}
 
export function InvoiceTable({ items, currency }: InvoiceTableProps) {
  return (
    <View style={styles.table}>
      {/* Table Header */}
      <View style={styles.tableHeader}>
        <Text style={[styles.tableHeaderCell, styles.descriptionCol]}>
          Description
        </Text>
        <Text style={[styles.tableHeaderCell, styles.quantityCol]}>Qty</Text>
        <Text style={[styles.tableHeaderCell, styles.priceCol]}>
          Unit Price
        </Text>
        <Text style={[styles.tableHeaderCell, styles.totalCol]}>Total</Text>
      </View>
 
      {/* Table Rows */}
      {items.map((item, index) => (
        <View
          key={index}
          style={index % 2 === 0 ? styles.tableRow : styles.tableRowAlt}
        >
          <Text style={[styles.tableCell, styles.descriptionCol]}>
            {item.description}
          </Text>
          <Text style={[styles.tableCell, styles.quantityCol]}>
            {item.quantity}
          </Text>
          <Text style={[styles.tableCell, styles.priceCol]}>
            {formatCurrency(item.unitPrice, currency)}
          </Text>
          <Text style={[styles.tableCell, styles.totalCol]}>
            {formatCurrency(item.total, currency)}
          </Text>
        </View>
      ))}
    </View>
  );
}

The footer shows subtotal, tax, and the final amount:

// src/components/pdf/InvoiceFooter.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceFooterProps {
  data: InvoiceData;
}
 
function formatCurrency(amount: number, currency: string): string {
  return `${currency} ${amount.toFixed(2)}`;
}
 
export function InvoiceFooter({ data }: InvoiceFooterProps) {
  return (
    <>
      {/* Totals */}
      <View style={styles.totalsContainer}>
        <View style={styles.totalsBox}>
          <View style={styles.totalsRow}>
            <Text>Subtotal</Text>
            <Text>{formatCurrency(data.subtotal, data.currency)}</Text>
          </View>
          <View style={styles.totalsRow}>
            <Text>Tax ({data.taxRate}%)</Text>
            <Text>{formatCurrency(data.taxAmount, data.currency)}</Text>
          </View>
          <View style={styles.totalsFinal}>
            <Text style={styles.totalsFinalText}>Total</Text>
            <Text style={styles.totalsFinalText}>
              {formatCurrency(data.total, data.currency)}
            </Text>
          </View>
        </View>
      </View>
 
      {/* Notes */}
      {data.notes && (
        <View style={styles.notes}>
          <Text style={styles.notesTitle}>Notes</Text>
          <Text style={styles.notesText}>{data.notes}</Text>
        </View>
      )}
 
      {/* Page Footer */}
      <View style={styles.footer} fixed>
        <Text>
          {data.company.name} | {data.company.email} | {data.company.phone}
        </Text>
        <Text style={{ marginTop: 2 }}>
          Thank you for your business
        </Text>
      </View>
    </>
  );
}

The fixed prop on the footer View means it will appear on every page if the invoice spans multiple pages.


Step 7: Assemble the Complete Invoice Document

Now combine all components into the main document:

// src/components/pdf/InvoiceDocument.tsx
 
import { Document, Page, View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import { InvoiceHeader } from "./InvoiceHeader";
import { InvoiceTable } from "./InvoiceTable";
import { InvoiceFooter } from "./InvoiceFooter";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceDocumentProps {
  data: InvoiceData;
}
 
export function InvoiceDocument({ data }: InvoiceDocumentProps) {
  return (
    <Document
      title={`Invoice ${data.invoiceNumber}`}
      author={data.company.name}
      subject={`Invoice for ${data.client.name}`}
      creator="PDF Invoice App"
    >
      <Page size="A4" style={styles.page}>
        {/* Header with company info and invoice meta */}
        <InvoiceHeader data={data} />
 
        {/* Bill To section */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>Bill To</Text>
          <Text style={{ fontFamily: "Helvetica-Bold", marginBottom: 2 }}>
            {data.client.name}
          </Text>
          <Text>{data.client.address}</Text>
          <Text>
            {data.client.city}, {data.client.country}
          </Text>
          <Text>{data.client.email}</Text>
        </View>
 
        {/* Line Items Table */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>Items</Text>
          <InvoiceTable items={data.items} currency={data.currency} />
        </View>
 
        {/* Footer with totals and notes */}
        <InvoiceFooter data={data} />
      </Page>
    </Document>
  );
}

The Document component accepts metadata props (title, author, subject) that are embedded into the PDF file — visible in PDF reader info panels.


Step 8: Create the PDF Generation API Route

This is where the magic happens. Create an API route that accepts invoice data and returns a PDF stream:

// src/app/api/invoice/route.ts
 
import { NextRequest, NextResponse } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
export async function POST(request: NextRequest) {
  try {
    const data: InvoiceData = await request.json();
 
    // Validate required fields
    if (!data.invoiceNumber || !data.items?.length) {
      return NextResponse.json(
        { error: "Invoice number and at least one item are required" },
        { status: 400 }
      );
    }
 
    // Generate the PDF buffer
    const pdfBuffer = await renderToBuffer(
      <InvoiceDocument data={data} />
    );
 
    // Return the PDF as a downloadable file
    return new NextResponse(pdfBuffer, {
      status: 200,
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `attachment; filename="invoice-${data.invoiceNumber}.pdf"`,
        "Content-Length": pdfBuffer.length.toString(),
      },
    });
  } catch (error) {
    console.error("PDF generation failed:", error);
    return NextResponse.json(
      { error: "Failed to generate PDF" },
      { status: 500 }
    );
  }
}

Key points about this endpoint:

  • renderToBuffer generates the PDF entirely in memory — no filesystem needed
  • Content-Disposition with attachment triggers a browser download
  • Content-Type is set to application/pdf so browsers handle it correctly
  • This runs server-side in Node.js — it works on Vercel, Railway, or any Node host

Step 9: Build the Invoice Form UI

Create the frontend form where users input invoice data:

// src/app/invoice/page.tsx
 
"use client";
 
import { useState } from "react";
import type { InvoiceData, InvoiceItem } from "@/lib/types";
 
const defaultItem: InvoiceItem = {
  description: "",
  quantity: 1,
  unitPrice: 0,
  total: 0,
};
 
const defaultInvoice: InvoiceData = {
  invoiceNumber: `INV-${Date.now().toString(36).toUpperCase()}`,
  date: new Date().toISOString().split("T")[0],
  dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
    .toISOString()
    .split("T")[0],
  company: {
    name: "Noqta Digital",
    address: "123 Tech Street",
    city: "Tunis",
    country: "Tunisia",
    email: "billing@noqta.tn",
    phone: "+216 71 000 000",
    taxId: "TN-12345678",
  },
  client: {
    name: "",
    address: "",
    city: "",
    country: "",
    email: "",
  },
  items: [{ ...defaultItem }],
  subtotal: 0,
  taxRate: 19,
  taxAmount: 0,
  total: 0,
  currency: "TND",
  notes: "Payment due within 30 days. Thank you for your business!",
};
 
export default function InvoicePage() {
  const [invoice, setInvoice] = useState<InvoiceData>(defaultInvoice);
  const [isGenerating, setIsGenerating] = useState(false);
 
  function updateItem(index: number, field: keyof InvoiceItem, value: string | number) {
    const updatedItems = [...invoice.items];
    updatedItems[index] = {
      ...updatedItems[index],
      [field]: value,
    };
 
    // Recalculate item total
    updatedItems[index].total =
      updatedItems[index].quantity * updatedItems[index].unitPrice;
 
    // Recalculate invoice totals
    const subtotal = updatedItems.reduce((sum, item) => sum + item.total, 0);
    const taxAmount = subtotal * (invoice.taxRate / 100);
 
    setInvoice({
      ...invoice,
      items: updatedItems,
      subtotal,
      taxAmount,
      total: subtotal + taxAmount,
    });
  }
 
  function addItem() {
    setInvoice({
      ...invoice,
      items: [...invoice.items, { ...defaultItem }],
    });
  }
 
  function removeItem(index: number) {
    const updatedItems = invoice.items.filter((_, i) => i !== index);
    const subtotal = updatedItems.reduce((sum, item) => sum + item.total, 0);
    const taxAmount = subtotal * (invoice.taxRate / 100);
 
    setInvoice({
      ...invoice,
      items: updatedItems,
      subtotal,
      taxAmount,
      total: subtotal + taxAmount,
    });
  }
 
  async function generatePDF() {
    setIsGenerating(true);
    try {
      const response = await fetch("/api/invoice", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(invoice),
      });
 
      if (!response.ok) {
        throw new Error("PDF generation failed");
      }
 
      // Download the PDF
      const blob = await response.blob();
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `invoice-${invoice.invoiceNumber}.pdf`;
      link.click();
      URL.revokeObjectURL(url);
    } catch (error) {
      console.error("Error:", error);
      alert("Failed to generate PDF. Please try again.");
    } finally {
      setIsGenerating(false);
    }
  }
 
  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8">Invoice Generator</h1>
 
      {/* Client Information */}
      <section className="mb-8 p-6 border rounded-lg">
        <h2 className="text-xl font-semibold mb-4">Client Information</h2>
        <div className="grid grid-cols-2 gap-4">
          <input
            type="text"
            placeholder="Client Name"
            className="border rounded px-3 py-2"
            value={invoice.client.name}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, name: e.target.value },
              })
            }
          />
          <input
            type="email"
            placeholder="Client Email"
            className="border rounded px-3 py-2"
            value={invoice.client.email}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, email: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="Address"
            className="border rounded px-3 py-2"
            value={invoice.client.address}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, address: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="City"
            className="border rounded px-3 py-2"
            value={invoice.client.city}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, city: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="Country"
            className="border rounded px-3 py-2"
            value={invoice.client.country}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, country: e.target.value },
              })
            }
          />
        </div>
      </section>
 
      {/* Line Items */}
      <section className="mb-8 p-6 border rounded-lg">
        <h2 className="text-xl font-semibold mb-4">Line Items</h2>
        <div className="space-y-3">
          {invoice.items.map((item, index) => (
            <div key={index} className="flex gap-3 items-center">
              <input
                type="text"
                placeholder="Description"
                className="border rounded px-3 py-2 flex-1"
                value={item.description}
                onChange={(e) =>
                  updateItem(index, "description", e.target.value)
                }
              />
              <input
                type="number"
                placeholder="Qty"
                className="border rounded px-3 py-2 w-20"
                value={item.quantity}
                onChange={(e) =>
                  updateItem(index, "quantity", Number(e.target.value))
                }
              />
              <input
                type="number"
                placeholder="Unit Price"
                className="border rounded px-3 py-2 w-32"
                value={item.unitPrice}
                onChange={(e) =>
                  updateItem(index, "unitPrice", Number(e.target.value))
                }
              />
              <span className="w-28 text-right font-mono">
                {invoice.currency} {item.total.toFixed(2)}
              </span>
              <button
                type="button"
                onClick={() => removeItem(index)}
                className="text-red-500 hover:text-red-700 px-2"
              >
                Remove
              </button>
            </div>
          ))}
        </div>
        <button
          type="button"
          onClick={addItem}
          className="mt-4 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded"
        >
          + Add Item
        </button>
      </section>
 
      {/* Summary */}
      <section className="mb-8 p-6 border rounded-lg bg-gray-50">
        <div className="flex justify-between mb-2">
          <span>Subtotal</span>
          <span>
            {invoice.currency} {invoice.subtotal.toFixed(2)}
          </span>
        </div>
        <div className="flex justify-between mb-2">
          <span>Tax ({invoice.taxRate}%)</span>
          <span>
            {invoice.currency} {invoice.taxAmount.toFixed(2)}
          </span>
        </div>
        <div className="flex justify-between font-bold text-lg border-t pt-2">
          <span>Total</span>
          <span>
            {invoice.currency} {invoice.total.toFixed(2)}
          </span>
        </div>
      </section>
 
      {/* Generate Button */}
      <button
        type="button"
        onClick={generatePDF}
        disabled={isGenerating}
        className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isGenerating ? "Generating PDF..." : "Download Invoice PDF"}
      </button>
    </div>
  );
}

Step 10: Add Custom Font Support

The built-in fonts (Helvetica, Courier, Times-Roman) work for English content. For Arabic, custom fonts, or branded typography, register TTF fonts:

// src/lib/register-fonts.ts
 
import { Font } from "@react-pdf/renderer";
 
// Register a custom font family
Font.register({
  family: "Inter",
  fonts: [
    {
      src: "https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.ttf",
      fontWeight: 400,
    },
    {
      src: "https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.ttf",
      fontWeight: 700,
    },
  ],
});
 
// Register an Arabic-supporting font
Font.register({
  family: "Noto Sans Arabic",
  src: "https://fonts.gstatic.com/s/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyG2vu3CBFQLaig.ttf",
});

Import this file at the top of your API route to ensure fonts are registered before rendering:

// src/app/api/invoice/route.ts
import "@/lib/register-fonts";
// ... rest of the route

Then use the custom font in your styles:

page: {
  fontFamily: "Inter",
  // ... other styles
},

Step 11: Add RTL (Arabic) Support

One powerful feature of @react-pdf/renderer is its ability to handle right-to-left text. This is essential for Arabic invoices:

// src/components/pdf/InvoiceDocumentArabic.tsx
 
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
import type { InvoiceData } from "@/lib/types";
 
const rtlStyles = StyleSheet.create({
  page: {
    padding: 40,
    fontSize: 10,
    fontFamily: "Noto Sans Arabic",
    direction: "rtl",
  },
  header: {
    flexDirection: "row-reverse",
    justifyContent: "space-between",
    marginBottom: 30,
  },
  text: {
    textAlign: "right",
  },
  tableHeader: {
    flexDirection: "row-reverse",
    backgroundColor: "#1a1a2e",
    padding: 8,
  },
  tableRow: {
    flexDirection: "row-reverse",
    padding: 8,
    borderBottomWidth: 1,
    borderBottomColor: "#e0e0e0",
  },
});
 
export function InvoiceDocumentArabic({ data }: { data: InvoiceData }) {
  return (
    <Document title={`فاتورة ${data.invoiceNumber}`}>
      <Page size="A4" style={rtlStyles.page}>
        <View style={rtlStyles.header}>
          <View>
            <Text style={{ fontSize: 28, textAlign: "right" }}>فاتورة</Text>
            <Text style={rtlStyles.text}>#{data.invoiceNumber}</Text>
          </View>
          <View>
            <Text style={{ fontSize: 22 }}>{data.company.name}</Text>
            <Text>{data.company.address}</Text>
          </View>
        </View>
 
        {/* Table with reversed direction */}
        <View style={rtlStyles.tableHeader}>
          <Text style={{ color: "#fff", width: "20%" }}>المجموع</Text>
          <Text style={{ color: "#fff", width: "20%" }}>السعر</Text>
          <Text style={{ color: "#fff", width: "15%" }}>الكمية</Text>
          <Text style={{ color: "#fff", width: "45%" }}>الوصف</Text>
        </View>
        {data.items.map((item, i) => (
          <View key={i} style={rtlStyles.tableRow}>
            <Text style={{ width: "20%" }}>
              {data.currency} {item.total.toFixed(2)}
            </Text>
            <Text style={{ width: "20%" }}>
              {data.currency} {item.unitPrice.toFixed(2)}
            </Text>
            <Text style={{ width: "15%", textAlign: "center" }}>
              {item.quantity}
            </Text>
            <Text style={{ width: "45%" }}>{item.description}</Text>
          </View>
        ))}
      </Page>
    </Document>
  );
}

The key RTL techniques:

  • direction: "rtl" on the page style
  • flexDirection: "row-reverse" to flip layout direction
  • textAlign: "right" for text alignment
  • Arabic font family registered with Font.register()

Step 12: Generate PDFs with Server Actions

For a more integrated approach, you can use Next.js Server Actions instead of a separate API route:

// src/app/invoice/actions.ts
 
"use server";
 
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
export async function generateInvoicePDF(
  data: InvoiceData
): Promise<{ pdf: string; filename: string }> {
  const pdfBuffer = await renderToBuffer(
    <InvoiceDocument data={data} />
  );
 
  // Convert buffer to base64 for client transport
  const base64 = Buffer.from(pdfBuffer).toString("base64");
 
  return {
    pdf: base64,
    filename: `invoice-${data.invoiceNumber}.pdf`,
  };
}

Use the Server Action on the client:

// In your client component
import { generateInvoicePDF } from "./actions";
 
async function handleGenerate() {
  const { pdf, filename } = await generateInvoicePDF(invoice);
 
  // Decode base64 and trigger download
  const bytes = Uint8Array.from(atob(pdf), (c) => c.charCodeAt(0));
  const blob = new Blob([bytes], { type: "application/pdf" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

Server Actions are convenient for simple cases, but the API route approach is better when you need to stream large PDFs or integrate with external services.


Step 13: Advanced - PDF Preview in Browser

For a better user experience, you can show a live preview before downloading. Use pdf() to generate a blob URL:

// src/components/pdf/PDFPreview.tsx
 
"use client";
 
import { useState, useEffect } from "react";
import { pdf } from "@react-pdf/renderer";
import { InvoiceDocument } from "./InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
interface PDFPreviewProps {
  data: InvoiceData;
}
 
export function PDFPreview({ data }: PDFPreviewProps) {
  const [url, setUrl] = useState<string | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    async function generatePreview() {
      const blob = await pdf(<InvoiceDocument data={data} />).toBlob();
      if (!cancelled) {
        const objectUrl = URL.createObjectURL(blob);
        setUrl(objectUrl);
      }
    }
 
    generatePreview();
 
    return () => {
      cancelled = true;
      if (url) URL.revokeObjectURL(url);
    };
  }, [data]);
 
  if (!url) {
    return (
      <div className="flex items-center justify-center h-[600px] bg-gray-100 rounded-lg">
        <p className="text-gray-500">Generating preview...</p>
      </div>
    );
  }
 
  return (
    <iframe
      src={url}
      className="w-full h-[600px] rounded-lg border"
      title="Invoice Preview"
    />
  );
}

Important: The pdf() function from @react-pdf/renderer runs entirely in the browser. This means the preview works client-side without hitting your server — great for instant feedback as users fill in the form.


Step 14: Batch PDF Generation

For generating multiple invoices at once (monthly billing, reports), create a batch endpoint:

// src/app/api/invoices/batch/route.ts
 
import { NextRequest, NextResponse } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
import { Readable } from "stream";
import archiver from "archiver";
 
export async function POST(request: NextRequest) {
  const { invoices }: { invoices: InvoiceData[] } = await request.json();
 
  if (!invoices?.length) {
    return NextResponse.json(
      { error: "No invoices provided" },
      { status: 400 }
    );
  }
 
  // Generate all PDFs concurrently
  const pdfPromises = invoices.map(async (invoice) => {
    const buffer = await renderToBuffer(
      <InvoiceDocument data={invoice} />
    );
    return {
      filename: `invoice-${invoice.invoiceNumber}.pdf`,
      buffer,
    };
  });
 
  const pdfs = await Promise.all(pdfPromises);
 
  // Create a ZIP archive
  const archive = archiver("zip", { zlib: { level: 9 } });
  const chunks: Uint8Array[] = [];
 
  archive.on("data", (chunk: Uint8Array) => chunks.push(chunk));
 
  for (const { filename, buffer } of pdfs) {
    archive.append(Buffer.from(buffer), { name: filename });
  }
 
  await archive.finalize();
  const zipBuffer = Buffer.concat(chunks);
 
  return new NextResponse(zipBuffer, {
    headers: {
      "Content-Type": "application/zip",
      "Content-Disposition": `attachment; filename="invoices-${Date.now()}.zip"`,
    },
  });
}

Install the archiver dependency:

npm install archiver
npm install -D @types/archiver

Testing Your Implementation

Start the development server and test the invoice generator:

npm run dev

Navigate to http://localhost:3000/invoice and:

  1. Fill in client details — enter a name, email, and address
  2. Add line items — add multiple items with quantities and prices
  3. Verify calculations — check that subtotal, tax, and total are correct
  4. Generate PDF — click the download button
  5. Open the PDF — verify the layout, fonts, and data match

You can also test the API directly with curl:

curl -X POST http://localhost:3000/api/invoice \
  -H "Content-Type: application/json" \
  -d '{
    "invoiceNumber": "INV-001",
    "date": "2026-04-10",
    "dueDate": "2026-05-10",
    "company": {
      "name": "Noqta Digital",
      "address": "123 Tech Street",
      "city": "Tunis",
      "country": "Tunisia",
      "email": "billing@noqta.tn",
      "phone": "+216 71 000 000"
    },
    "client": {
      "name": "Acme Corp",
      "address": "456 Business Ave",
      "city": "Dubai",
      "country": "UAE",
      "email": "accounts@acme.com"
    },
    "items": [
      {"description": "Web Development", "quantity": 40, "unitPrice": 45, "total": 1800},
      {"description": "UI/UX Design", "quantity": 20, "unitPrice": 50, "total": 1000}
    ],
    "subtotal": 2800,
    "taxRate": 19,
    "taxAmount": 532,
    "total": 3332,
    "currency": "TND",
    "notes": "Payment due within 30 days."
  }' \
  --output invoice.pdf

Troubleshooting

Common Issues and Solutions

"Cannot find module @react-pdf/renderer" This usually happens when Next.js tries to bundle React-PDF for the client. Ensure you only import renderToBuffer in server-side files (API routes, Server Actions). Use dynamic imports if needed:

const { renderToBuffer } = await import("@react-pdf/renderer");

"Invalid hook call" errors React-PDF components are NOT React DOM components. Never render them inside a regular React component tree. They can only be passed to renderToBuffer, renderToStream, or pdf().

Custom fonts not loading Font URLs must be accessible at build/render time. For self-hosted fonts, place them in the public/ directory and use absolute URLs:

Font.register({
  family: "MyFont",
  src: "/fonts/MyFont-Regular.ttf",
});

PDF is blank or empty Ensure your data is not undefined. Add console logs in the API route to verify the data reaches the render function. Also check that text content is wrapped in <Text> components — bare strings inside <View> will not render.

Large PDFs are slow For documents with many pages, use renderToStream instead of renderToBuffer to avoid loading the entire PDF into memory:

import { renderToStream } from "@react-pdf/renderer";
 
const stream = await renderToStream(<InvoiceDocument data={data} />);
return new NextResponse(stream as any, {
  headers: { "Content-Type": "application/pdf" },
});

Performance Tips

  1. Cache fonts — Register fonts once at module level, not per request
  2. Use renderToStream for documents larger than 50 pages
  3. Minimize re-renders in the preview component with useMemo
  4. Pre-compute totals on the server instead of in PDF components
  5. Compress images before embedding them in PDFs (use WebP or compressed PNG)

Next Steps

Now that you have a working PDF generation system, consider these enhancements:

  • Email integration — Send invoices as email attachments with Resend
  • Database storage — Save invoice data with Drizzle ORM or Prisma
  • Authentication — Protect the invoice endpoint with Better Auth
  • Scheduled reports — Generate monthly reports with Trigger.dev
  • Templates — Create report, receipt, and certificate templates
  • Digital signatures — Add PDF signing for legal compliance

Conclusion

You have built a complete PDF generation system using @react-pdf/renderer and Next.js 15. The setup is serverless-friendly, type-safe, and uses familiar React component patterns.

The key takeaways are:

  • @react-pdf/renderer generates PDFs with React components — no headless browser needed
  • Flexbox layout makes positioning natural and predictable
  • API routes provide a clean endpoint for PDF generation and download
  • Server Actions offer a simpler alternative for in-app PDF creation
  • Custom fonts and RTL support make it suitable for multilingual documents
  • Streaming enables efficient generation of large documents

PDFs are a fundamental requirement for business applications — invoices, reports, contracts, certificates. With this foundation, you can build any document template your application needs.


Want to read more tutorials? Check out our latest tutorial on Exploring the New Responses API: A Comprehensive Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles