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

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:
| Approach | Serverless-Ready | Layout Control | Bundle Size | Complexity |
|---|---|---|---|---|
| @react-pdf/renderer | Yes | Full (Flexbox) | ~500 KB | Low |
| Puppeteer | No (needs Chrome) | Full (HTML/CSS) | ~300 MB | High |
| jsPDF | Yes | Manual coordinates | ~300 KB | Medium |
| PDFKit | Yes | Manual coordinates | ~1 MB | Medium |
| html-pdf | No (deprecated) | HTML/CSS subset | Varies | Medium |
@react-pdf/renderer is the clear winner for React/Next.js projects because:
- You already know the API — it is JSX with React components
- No headless browser — generates PDFs natively, works in serverless
- Flexbox layout — position elements naturally, not with x/y coordinates
- Streaming support — generate and stream large PDFs efficiently
- 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-appInstall the required dependencies:
npm install @react-pdf/rendererThat 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
paddingTopinstead ofpadding: "10 0" - Flexbox is default — every
Viewis a flex container - Units are points — 1pt = 1/72 inch (standard PDF unit)
- Limited properties — only a subset of CSS is supported (no
grid, nofloat)
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>
);
}Step 6: Build the Invoice Footer with Totals
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:
renderToBuffergenerates the PDF entirely in memory — no filesystem needed- Content-Disposition with
attachmenttriggers a browser download - Content-Type is set to
application/pdfso 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 routeThen 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 styleflexDirection: "row-reverse"to flip layout directiontextAlign: "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/archiverTesting Your Implementation
Start the development server and test the invoice generator:
npm run devNavigate to http://localhost:3000/invoice and:
- Fill in client details — enter a name, email, and address
- Add line items — add multiple items with quantities and prices
- Verify calculations — check that subtotal, tax, and total are correct
- Generate PDF — click the download button
- 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.pdfTroubleshooting
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
- Cache fonts — Register fonts once at module level, not per request
- Use
renderToStreamfor documents larger than 50 pages - Minimize re-renders in the preview component with
useMemo - Pre-compute totals on the server instead of in PDF components
- 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.
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

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Build a Full-Stack App with Prisma ORM and Next.js 15 App Router
Learn how to build a full-stack application with Prisma ORM, Next.js 15 App Router, and PostgreSQL. This tutorial covers schema modeling, migrations, Server Actions, CRUD operations, relations, and production deployment.

Zod v4 with Next.js 15: Complete Schema Validation for Forms, APIs, and Server Actions
Master Zod v4 in Next.js 15 — validate forms with Server Actions, secure API routes, parse environment variables, and build end-to-end type-safe apps with the fastest TypeScript schema library.