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

Noqta Team
Par Noqta Team ·

Chargement du lecteur de synthèse vocale...

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 :

ApprocheServerless-ReadyContrôle de mise en pageTaille du bundleComplexité
@react-pdf/rendererOuiComplet (Flexbox)~500 KoFaible
PuppeteerNon (nécessite Chrome)Complet (HTML/CSS)~300 MoÉlevée
jsPDFOuiCoordonnées manuelles~300 KoMoyenne
PDFKitOuiCoordonnées manuelles~1 MoMoyenne
html-pdfNon (obsolète)Sous-ensemble HTML/CSSVariableMoyenne

@react-pdf/renderer est le choix évident pour les projets React/Next.js parce que :

  1. Vous connaissez déjà l'API — c'est du JSX avec des composants React
  2. Pas de navigateur headless — génère les PDF nativement, fonctionne en serverless
  3. Mise en page Flexbox — positionnez les éléments naturellement, pas avec des coordonnées x/y
  4. Support du streaming — générez et diffusez efficacement de gros PDF
  5. 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-app

Installez les dépendances requises :

npm install @react-pdf/renderer

C'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 paddingTop au lieu de padding: "10 0"
  • Flexbox par défaut — chaque View est 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 de float)

É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 :

  • renderToBuffer génère le PDF entièrement en mémoire — pas besoin de système de fichiers
  • Content-Disposition avec attachment déclenche un téléchargement navigateur
  • Content-Type est défini sur application/pdf pour 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 route

Puis 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 page
  • flexDirection: "row-reverse" pour inverser la direction de mise en page
  • textAlign: "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/archiver

Tester votre implémentation

Démarrez le serveur de développement et testez le générateur de factures :

npm run dev

Naviguez vers http://localhost:3000/invoice et :

  1. Remplissez les détails du client — entrez un nom, email et adresse
  2. Ajoutez des éléments — ajoutez plusieurs éléments avec quantités et prix
  3. Vérifiez les calculs — confirmez que le sous-total, la TVA et le total sont corrects
  4. Générez le PDF — cliquez sur le bouton de téléchargement
  5. 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.pdf

Dé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

  1. Mettez en cache les polices — enregistrez les polices une seule fois au niveau du module, pas par requête
  2. Utilisez renderToStream pour les documents de plus de 50 pages
  3. Minimisez les re-rendus dans le composant d'aperçu avec useMemo
  4. Pré-calculez les totaux sur le serveur plutôt que dans les composants PDF
  5. 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Fine-tuning de Gemma pour l'appel de fonctions.

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