React-PDF + Next.js 15 : Générer des PDF professionnels, factures et rapports avec TypeScript

Générez des PDF pixel-perfect avec des composants React. @react-pdf/renderer vous permet de construire des PDF en utilisant la syntaxe JSX familière — pas de navigateurs headless, pas de hacks HTML-to-PDF. Dans ce tutoriel, vous allez construire un système complet de génération de factures avec Next.js 15, Server Actions et des documents PDF téléchargeables.
Ce que vous apprendrez
À la fin de ce tutoriel, vous serez capable de :
- Configurer @react-pdf/renderer dans un projet Next.js 15 avec TypeScript
- Construire des composants PDF réutilisables avec les primitives React-PDF (
Document,Page,View,Text) - Créer un modèle de facture professionnel avec des tableaux, en-têtes et pieds de page
- Générer des PDF côté serveur avec les routes API Next.js et les Server Actions
- Ajouter des données dynamiques depuis des formulaires pour produire des documents personnalisés
- Implémenter un endpoint de téléchargement qui diffuse les PDF vers le navigateur
- Supporter les polices personnalisées et les langues RTL (arabe) dans vos documents
- Déployer un pipeline de génération PDF prêt pour la production
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (
node --version) - Expérience TypeScript (types, interfaces, async/await)
- Familiarité avec Next.js (App Router, Server Components, routes API)
- Connaissance de React (composants, props, JSX)
- Un éditeur de code — VS Code ou Cursor recommandé
Pourquoi @react-pdf/renderer ?
Il existe plusieurs approches pour la génération de PDF en JavaScript. Voici comment elles se comparent :
| Approche | Serverless-Ready | Contrôle de mise en page | Taille du bundle | Complexité |
|---|---|---|---|---|
| @react-pdf/renderer | Oui | Complet (Flexbox) | ~500 Ko | Faible |
| Puppeteer | Non (nécessite Chrome) | Complet (HTML/CSS) | ~300 Mo | Élevée |
| jsPDF | Oui | Coordonnées manuelles | ~300 Ko | Moyenne |
| PDFKit | Oui | Coordonnées manuelles | ~1 Mo | Moyenne |
| html-pdf | Non (obsolète) | Sous-ensemble HTML/CSS | Variable | Moyenne |
@react-pdf/renderer est le choix évident pour les projets React/Next.js parce que :
- Vous connaissez déjà l'API — c'est du JSX avec des composants React
- Pas de navigateur headless — génère les PDF nativement, fonctionne en serverless
- Mise en page Flexbox — positionnez les éléments naturellement, pas avec des coordonnées x/y
- Support du streaming — générez et diffusez efficacement de gros PDF
- Polices personnalisées — enregistrez n'importe quelle police TTF/OTF, y compris les polices arabes
Étape 1 : Configuration du projet
Créez un nouveau projet Next.js 15 avec TypeScript :
npx create-next-app@latest pdf-invoice-app --typescript --tailwind --app --src-dir
cd pdf-invoice-appInstallez les dépendances requises :
npm install @react-pdf/rendererC'est la seule dépendance nécessaire pour la génération de PDF. La bibliothèque fournit tout : structure du document, stylisation, polices et rendu.
Votre structure de projet ressemblera à ceci :
pdf-invoice-app/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── invoice/
│ │ │ └── route.ts # Endpoint de génération PDF
│ │ ├── invoice/
│ │ │ └── page.tsx # Page du formulaire de facture
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ │ └── pdf/
│ │ ├── InvoiceDocument.tsx # Document PDF principal
│ │ ├── InvoiceHeader.tsx # Composant en-tête
│ │ ├── InvoiceTable.tsx # Tableau des éléments
│ │ ├── InvoiceFooter.tsx # Pied de page avec totaux
│ │ └── styles.ts # Styles PDF partagés
│ └── lib/
│ └── types.ts # Types TypeScript pour la facture
├── public/
│ └── fonts/ # Polices personnalisées (optionnel)
├── package.json
└── tsconfig.json
Étape 2 : Définir les types de facture
Commencez par définir les types TypeScript pour vos données de facture. Créez le fichier de types :
// src/lib/types.ts
export interface InvoiceItem {
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface InvoiceData {
invoiceNumber: string;
date: string;
dueDate: string;
// Expéditeur
company: {
name: string;
address: string;
city: string;
country: string;
email: string;
phone: string;
taxId?: string;
};
// Destinataire
client: {
name: string;
address: string;
city: string;
country: string;
email: string;
};
// Éléments de ligne
items: InvoiceItem[];
// Données financières
subtotal: number;
taxRate: number;
taxAmount: number;
total: number;
// Optionnel
notes?: string;
currency: string;
}Ces types seront partagés entre le formulaire, la route API et les composants PDF — vous offrant une sécurité de types de bout en bout.
Étape 3 : Créer les styles PDF
React-PDF utilise une API StyleSheet similaire à React Native. Définissez vos styles partagés :
// 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,
},
// Styles de tableau
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" },
// Totaux
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,
},
// Pied de page
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,
},
});Différences clés avec le CSS standard :
- Pas de propriétés raccourcies — utilisez
paddingTopau lieu depadding: "10 0" - Flexbox par défaut — chaque
Viewest un conteneur flex - Les unités sont des points — 1pt = 1/72 pouce (unité PDF standard)
- Propriétés limitées — seul un sous-ensemble de CSS est supporté (pas de
grid, pas defloat)
Étape 4 : Construire l'en-tête de facture
Créez le composant d'en-tête qui affiche les informations de l'entreprise et les métadonnées de la facture :
// 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}>
{/* Gauche : Infos entreprise */}
<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 }}>
N° TVA : {data.company.taxId}
</Text>
)}
</View>
{/* Droite : Méta facture */}
<View>
<Text style={styles.invoiceTitle}>FACTURE</Text>
<Text style={styles.invoiceMeta}>#{data.invoiceNumber}</Text>
<Text style={styles.invoiceMeta}>Date : {data.date}</Text>
<Text style={styles.invoiceMeta}>Échéance : {data.dueDate}</Text>
</View>
</View>
);
}Étape 5 : Construire le tableau des éléments
Le composant tableau affiche chaque élément de la facture avec des couleurs de lignes alternées :
// 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}>
{/* En-tête du tableau */}
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, styles.descriptionCol]}>
Description
</Text>
<Text style={[styles.tableHeaderCell, styles.quantityCol]}>Qté</Text>
<Text style={[styles.tableHeaderCell, styles.priceCol]}>
Prix unitaire
</Text>
<Text style={[styles.tableHeaderCell, styles.totalCol]}>Total</Text>
</View>
{/* Lignes du tableau */}
{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>
);
}Étape 6 : Construire le pied de page avec les totaux
Le pied de page affiche le sous-total, la taxe et le montant final :
// 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 (
<>
{/* Totaux */}
<View style={styles.totalsContainer}>
<View style={styles.totalsBox}>
<View style={styles.totalsRow}>
<Text>Sous-total</Text>
<Text>{formatCurrency(data.subtotal, data.currency)}</Text>
</View>
<View style={styles.totalsRow}>
<Text>TVA ({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>
)}
{/* Pied de page */}
<View style={styles.footer} fixed>
<Text>
{data.company.name} | {data.company.email} | {data.company.phone}
</Text>
<Text style={{ marginTop: 2 }}>
Merci pour votre confiance
</Text>
</View>
</>
);
}La propriété fixed sur le View du pied de page signifie qu'il apparaîtra sur chaque page si la facture s'étend sur plusieurs pages.
Étape 7 : Assembler le document de facture complet
Combinez maintenant tous les composants dans le document principal :
// 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={`Facture ${data.invoiceNumber}`}
author={data.company.name}
subject={`Facture pour ${data.client.name}`}
creator="PDF Invoice App"
>
<Page size="A4" style={styles.page}>
{/* En-tête avec infos entreprise et méta facture */}
<InvoiceHeader data={data} />
{/* Section facturation */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Facturer à</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>
{/* Tableau des éléments */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Éléments</Text>
<InvoiceTable items={data.items} currency={data.currency} />
</View>
{/* Pied de page avec totaux et notes */}
<InvoiceFooter data={data} />
</Page>
</Document>
);
}Le composant Document accepte des propriétés de métadonnées (title, author, subject) qui sont intégrées dans le fichier PDF — visibles dans les panneaux d'information des lecteurs PDF.
Étape 8 : Créer la route API de génération PDF
C'est ici que la magie opère. Créez une route API qui accepte les données de facture et retourne un flux PDF :
// 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();
// Valider les champs requis
if (!data.invoiceNumber || !data.items?.length) {
return NextResponse.json(
{ error: "Le numéro de facture et au moins un élément sont requis" },
{ status: 400 }
);
}
// Générer le buffer PDF
const pdfBuffer = await renderToBuffer(
<InvoiceDocument data={data} />
);
// Retourner le PDF comme fichier téléchargeable
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="facture-${data.invoiceNumber}.pdf"`,
"Content-Length": pdfBuffer.length.toString(),
},
});
} catch (error) {
console.error("Échec de la génération PDF :", error);
return NextResponse.json(
{ error: "Impossible de générer le PDF" },
{ status: 500 }
);
}
}Points clés sur cet endpoint :
renderToBuffergénère le PDF entièrement en mémoire — pas besoin de système de fichiers- Content-Disposition avec
attachmentdéclenche un téléchargement navigateur - Content-Type est défini sur
application/pdfpour que les navigateurs le gèrent correctement - Cela fonctionne côté serveur dans Node.js — compatible avec Vercel, Railway, ou tout hébergeur Node
Étape 9 : Construire l'interface du formulaire de facture
Créez le formulaire frontend où les utilisateurs saisissent les données de facture :
// 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 Rue de la Technologie",
city: "Tunis",
country: "Tunisie",
email: "facturation@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: "Paiement dû sous 30 jours. Merci pour votre confiance !",
};
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,
};
// Recalculer le total de l'élément
updatedItems[index].total =
updatedItems[index].quantity * updatedItems[index].unitPrice;
// Recalculer les totaux de la facture
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("Échec de la génération PDF");
}
// Télécharger le PDF
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `facture-${invoice.invoiceNumber}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error("Erreur :", error);
alert("Impossible de générer le PDF. Veuillez réessayer.");
} finally {
setIsGenerating(false);
}
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Générateur de factures</h1>
{/* Informations client */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-semibold mb-4">Informations client</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
placeholder="Nom du client"
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="Email du client"
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="Adresse"
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="Ville"
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="Pays"
className="border rounded px-3 py-2"
value={invoice.client.country}
onChange={(e) =>
setInvoice({
...invoice,
client: { ...invoice.client, country: e.target.value },
})
}
/>
</div>
</section>
{/* Éléments de ligne */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-semibold mb-4">Éléments de la facture</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="Qté"
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="Prix unitaire"
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"
>
Supprimer
</button>
</div>
))}
</div>
<button
type="button"
onClick={addItem}
className="mt-4 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded"
>
+ Ajouter un élément
</button>
</section>
{/* Résumé */}
<section className="mb-8 p-6 border rounded-lg bg-gray-50">
<div className="flex justify-between mb-2">
<span>Sous-total</span>
<span>
{invoice.currency} {invoice.subtotal.toFixed(2)}
</span>
</div>
<div className="flex justify-between mb-2">
<span>TVA ({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>
{/* Bouton de génération */}
<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 ? "Génération du PDF..." : "Télécharger la facture PDF"}
</button>
</div>
);
}Étape 10 : Ajouter le support des polices personnalisées
Les polices intégrées (Helvetica, Courier, Times-Roman) fonctionnent pour le contenu en anglais. Pour l'arabe, les polices personnalisées ou la typographie de marque, enregistrez des polices TTF :
// src/lib/register-fonts.ts
import { Font } from "@react-pdf/renderer";
// Enregistrer une famille de polices personnalisée
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,
},
],
});
// Enregistrer une police supportant l'arabe
Font.register({
family: "Noto Sans Arabic",
src: "https://fonts.gstatic.com/s/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyG2vu3CBFQLaig.ttf",
});Importez ce fichier en haut de votre route API pour vous assurer que les polices sont enregistrées avant le rendu :
// src/app/api/invoice/route.ts
import "@/lib/register-fonts";
// ... reste de la routePuis utilisez la police personnalisée dans vos styles :
page: {
fontFamily: "Inter",
// ... autres styles
},Étape 11 : Ajouter le support RTL (arabe)
L'une des fonctionnalités puissantes de @react-pdf/renderer est sa capacité à gérer le texte de droite à gauche. C'est essentiel pour les factures en arabe :
// 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>
{/* Tableau avec direction inversée */}
<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>
);
}Les techniques clés pour le RTL :
direction: "rtl"sur le style de pageflexDirection: "row-reverse"pour inverser la direction de mise en pagetextAlign: "right"pour l'alignement du texte- Famille de police arabe enregistrée avec
Font.register()
Étape 12 : Générer des PDF avec les Server Actions
Pour une approche plus intégrée, vous pouvez utiliser les Server Actions de Next.js au lieu d'une route API séparée :
// 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} />
);
// Convertir le buffer en base64 pour le transport client
const base64 = Buffer.from(pdfBuffer).toString("base64");
return {
pdf: base64,
filename: `facture-${data.invoiceNumber}.pdf`,
};
}Utilisez la Server Action côté client :
// Dans votre composant client
import { generateInvoicePDF } from "./actions";
async function handleGenerate() {
const { pdf, filename } = await generateInvoicePDF(invoice);
// Décoder le base64 et déclencher le téléchargement
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);
}Les Server Actions sont pratiques pour les cas simples, mais l'approche route API est meilleure quand vous devez diffuser de gros PDF ou intégrer des services externes.
Étape 13 : Avancé - Aperçu PDF dans le navigateur
Pour une meilleure expérience utilisateur, vous pouvez afficher un aperçu en direct avant le téléchargement. Utilisez pdf() pour générer une URL blob :
// 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">Génération de l'aperçu...</p>
</div>
);
}
return (
<iframe
src={url}
className="w-full h-[600px] rounded-lg border"
title="Aperçu de la facture"
/>
);
}Important : La fonction pdf() de @react-pdf/renderer s'exécute entièrement dans le navigateur. Cela signifie que l'aperçu fonctionne côté client sans contacter votre serveur — idéal pour un retour instantané pendant que les utilisateurs remplissent le formulaire.
Étape 14 : Génération PDF par lots
Pour générer plusieurs factures en une fois (facturation mensuelle, rapports), créez un endpoint par lots :
// 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 archiver from "archiver";
export async function POST(request: NextRequest) {
const { invoices }: { invoices: InvoiceData[] } = await request.json();
if (!invoices?.length) {
return NextResponse.json(
{ error: "Aucune facture fournie" },
{ status: 400 }
);
}
// Générer tous les PDF simultanément
const pdfPromises = invoices.map(async (invoice) => {
const buffer = await renderToBuffer(
<InvoiceDocument data={invoice} />
);
return {
filename: `facture-${invoice.invoiceNumber}.pdf`,
buffer,
};
});
const pdfs = await Promise.all(pdfPromises);
// Créer une archive ZIP
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="factures-${Date.now()}.zip"`,
},
});
}Installez la dépendance archiver :
npm install archiver
npm install -D @types/archiverTester votre implémentation
Démarrez le serveur de développement et testez le générateur de factures :
npm run devNaviguez vers http://localhost:3000/invoice et :
- Remplissez les détails du client — entrez un nom, email et adresse
- Ajoutez des éléments — ajoutez plusieurs éléments avec quantités et prix
- Vérifiez les calculs — confirmez que le sous-total, la TVA et le total sont corrects
- Générez le PDF — cliquez sur le bouton de téléchargement
- Ouvrez le PDF — vérifiez que la mise en page, les polices et les données correspondent
Vous pouvez aussi tester l'API directement avec 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 Rue de la Technologie",
"city": "Tunis",
"country": "Tunisie",
"email": "facturation@noqta.tn",
"phone": "+216 71 000 000"
},
"client": {
"name": "Acme Corp",
"address": "456 Avenue des Affaires",
"city": "Paris",
"country": "France",
"email": "comptes@acme.com"
},
"items": [
{"description": "Développement web", "quantity": 40, "unitPrice": 45, "total": 1800},
{"description": "Design UI/UX", "quantity": 20, "unitPrice": 50, "total": 1000}
],
"subtotal": 2800,
"taxRate": 19,
"taxAmount": 532,
"total": 3332,
"currency": "TND",
"notes": "Paiement dû sous 30 jours."
}' \
--output facture.pdfDépannage
Problèmes courants et solutions
"Cannot find module @react-pdf/renderer"
Cela se produit généralement quand Next.js essaie de bundler React-PDF pour le client. Assurez-vous de n'importer renderToBuffer que dans les fichiers côté serveur (routes API, Server Actions). Utilisez des imports dynamiques si nécessaire :
const { renderToBuffer } = await import("@react-pdf/renderer");Erreurs "Invalid hook call"
Les composants React-PDF ne sont PAS des composants React DOM. Ne les rendez jamais dans une arborescence de composants React classique. Ils ne peuvent être passés qu'à renderToBuffer, renderToStream ou pdf().
Les polices personnalisées ne chargent pas
Les URLs des polices doivent être accessibles au moment du build/rendu. Pour les polices auto-hébergées, placez-les dans le répertoire public/ et utilisez des URLs absolues :
Font.register({
family: "MyFont",
src: "/fonts/MyFont-Regular.ttf",
});Le PDF est vide ou blanc
Assurez-vous que vos données ne sont pas undefined. Ajoutez des console logs dans la route API pour vérifier que les données atteignent la fonction de rendu. Vérifiez aussi que le contenu textuel est enveloppé dans des composants <Text> — les chaînes nues à l'intérieur de <View> ne seront pas rendues.
Les gros PDF sont lents
Pour les documents avec beaucoup de pages, utilisez renderToStream au lieu de renderToBuffer pour éviter de charger le PDF entier en mémoire :
import { renderToStream } from "@react-pdf/renderer";
const stream = await renderToStream(<InvoiceDocument data={data} />);
return new NextResponse(stream as any, {
headers: { "Content-Type": "application/pdf" },
});Conseils de performance
- Mettez en cache les polices — enregistrez les polices une seule fois au niveau du module, pas par requête
- Utilisez
renderToStreampour les documents de plus de 50 pages - Minimisez les re-rendus dans le composant d'aperçu avec
useMemo - Pré-calculez les totaux sur le serveur plutôt que dans les composants PDF
- Compressez les images avant de les intégrer dans les PDF (utilisez WebP ou PNG compressé)
Prochaines étapes
Maintenant que vous avez un système de génération PDF fonctionnel, considérez ces améliorations :
- Intégration email — Envoyez les factures en pièces jointes avec Resend
- Stockage en base de données — Sauvegardez les données de facture avec Drizzle ORM ou Prisma
- Authentification — Protégez l'endpoint de facture avec Better Auth
- Rapports programmés — Générez des rapports mensuels avec Trigger.dev
- Modèles — Créez des modèles de rapports, reçus et certificats
- Signatures numériques — Ajoutez la signature PDF pour la conformité légale
Conclusion
Vous avez construit un système complet de génération PDF en utilisant @react-pdf/renderer et Next.js 15. La configuration est compatible serverless, type-safe, et utilise les patterns familiers des composants React.
Les points clés à retenir sont :
- @react-pdf/renderer génère des PDF avec des composants React — pas besoin de navigateur headless
- La mise en page Flexbox rend le positionnement naturel et prévisible
- Les routes API fournissent un endpoint propre pour la génération et le téléchargement de PDF
- Les Server Actions offrent une alternative plus simple pour la création de PDF dans l'application
- Les polices personnalisées et le support RTL le rendent adapté aux documents multilingues
- Le streaming permet la génération efficace de documents volumineux
Les PDF sont un besoin fondamental pour les applications métier — factures, rapports, contrats, certificats. Avec cette base, vous pouvez construire n'importe quel modèle de document dont votre application a besoin.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Construire une application full-stack avec Prisma ORM et Next.js 15 App Router
Apprenez à construire une application full-stack avec Prisma ORM, Next.js 15 App Router et PostgreSQL. Ce tutoriel couvre la modélisation du schéma, les migrations, les Server Actions, les opérations CRUD, les relations et le déploiement en production.

Zod v4 avec Next.js 15 : Validation complète des schémas pour les formulaires, APIs et Server Actions
Maîtrisez Zod v4 dans Next.js 15 — validez les formulaires avec Server Actions, sécurisez les routes API, parsez les variables d'environnement et construisez des applications entièrement type-safe avec la bibliothèque de validation TypeScript la plus rapide.