React-PDF + Next.js 15: إنشاء ملفات PDF احترافية وفواتير وتقارير باستخدام TypeScript

أنشئ ملفات PDF مثالية باستخدام مكونات React. تتيح لك مكتبة @react-pdf/renderer بناء مستندات PDF باستخدام صيغة JSX المألوفة — بدون متصفحات بدون واجهة، وبدون حيل تحويل HTML إلى PDF. في هذا الدليل، ستبني نظام إنشاء فواتير متكامل مع Next.js 15 وServer Actions ومستندات PDF قابلة للتحميل.
ما ستتعلمه
بنهاية هذا الدليل، ستتمكن من:
- إعداد @react-pdf/renderer في مشروع Next.js 15 مع TypeScript
- بناء مكونات PDF قابلة لإعادة الاستخدام باستخدام عناصر React-PDF الأساسية (
Document،Page،View،Text) - إنشاء قالب فاتورة احترافي مع جداول ورؤوس وتذييلات
- توليد ملفات PDF على جانب الخادم باستخدام مسارات API في Next.js وServer Actions
- إضافة بيانات ديناميكية من النماذج لإنتاج مستندات مخصصة
- تنفيذ نقطة نهاية للتحميل تبث ملفات PDF إلى المتصفح
- دعم الخطوط المخصصة واللغات من اليمين إلى اليسار (العربية) في مستنداتك
- نشر خط أنابيب إنشاء PDF جاهز للإنتاج
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت (
node --version) - خبرة في TypeScript (الأنواع، الواجهات، async/await)
- معرفة بـ Next.js (App Router، Server Components، مسارات API)
- معرفة بـ React (المكونات، الخصائص، JSX)
- محرر أكواد — يُنصح باستخدام VS Code أو Cursor
لماذا @react-pdf/renderer؟
هناك عدة طرق لإنشاء ملفات PDF في JavaScript. إليك مقارنة بينها:
| الطريقة | جاهز للـ Serverless | التحكم في التخطيط | حجم الحزمة | التعقيد |
|---|---|---|---|---|
| @react-pdf/renderer | نعم | كامل (Flexbox) | ~500 كيلوبايت | منخفض |
| Puppeteer | لا (يحتاج Chrome) | كامل (HTML/CSS) | ~300 ميغابايت | عالي |
| jsPDF | نعم | إحداثيات يدوية | ~300 كيلوبايت | متوسط |
| PDFKit | نعم | إحداثيات يدوية | ~1 ميغابايت | متوسط |
| html-pdf | لا (مهمل) | مجموعة فرعية من HTML/CSS | متغير | متوسط |
@react-pdf/renderer هو الخيار الأفضل لمشاريع React/Next.js لأن:
- أنت تعرف الواجهة البرمجية بالفعل — إنها JSX مع مكونات React
- لا حاجة لمتصفح بدون واجهة — تولد ملفات PDF أصلياً، تعمل في بيئة serverless
- تخطيط Flexbox — ضع العناصر بشكل طبيعي، ليس بإحداثيات x/y
- دعم البث — أنشئ وابث ملفات PDF كبيرة بكفاءة
- خطوط مخصصة — سجل أي خط TTF/OTF، بما في ذلك الخطوط العربية
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js 15 جديد مع TypeScript:
npx create-next-app@latest pdf-invoice-app --typescript --tailwind --app --src-dir
cd pdf-invoice-appثبت التبعيات المطلوبة:
npm install @react-pdf/rendererهذه هي التبعية الوحيدة التي تحتاجها لإنشاء PDF. توفر المكتبة كل شيء: هيكل المستند، والتنسيق، والخطوط، والعرض.
هيكل المشروع سيكون كالتالي:
pdf-invoice-app/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── invoice/
│ │ │ └── route.ts # نقطة نهاية إنشاء PDF
│ │ ├── invoice/
│ │ │ └── page.tsx # صفحة نموذج الفاتورة
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ │ └── pdf/
│ │ ├── InvoiceDocument.tsx # مستند PDF الرئيسي
│ │ ├── InvoiceHeader.tsx # مكون الرأس
│ │ ├── InvoiceTable.tsx # جدول العناصر
│ │ ├── InvoiceFooter.tsx # التذييل مع الإجماليات
│ │ └── styles.ts # أنماط PDF المشتركة
│ └── lib/
│ └── types.ts # أنواع TypeScript للفاتورة
├── public/
│ └── fonts/ # خطوط مخصصة (اختياري)
├── package.json
└── tsconfig.json
الخطوة 2: تعريف أنواع الفاتورة
ابدأ بتعريف أنواع TypeScript لبيانات الفاتورة. أنشئ ملف الأنواع:
// src/lib/types.ts
export interface InvoiceItem {
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface InvoiceData {
invoiceNumber: string;
date: string;
dueDate: string;
// المرسل
company: {
name: string;
address: string;
city: string;
country: string;
email: string;
phone: string;
taxId?: string;
};
// المستلم
client: {
name: string;
address: string;
city: string;
country: string;
email: string;
};
// عناصر السطر
items: InvoiceItem[];
// البيانات المالية
subtotal: number;
taxRate: number;
taxAmount: number;
total: number;
// اختياري
notes?: string;
currency: string;
}ستتم مشاركة هذه الأنواع بين النموذج ومسار API ومكونات PDF — مما يمنحك أماناً كاملاً للأنواع من البداية إلى النهاية.
الخطوة 3: إنشاء أنماط PDF
يستخدم React-PDF واجهة StyleSheet مشابهة لـ React Native. عرف الأنماط المشتركة:
// 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: {
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" },
// الإجماليات
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: {
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,
},
});الفروقات الرئيسية عن CSS العادي:
- لا توجد خصائص مختصرة — استخدم
paddingTopبدلاً منpadding: "10 0" - Flexbox هو الافتراضي — كل
Viewهو حاوية flex - الوحدات هي نقاط — 1pt = 1/72 بوصة (وحدة PDF القياسية)
- خصائص محدودة — مجموعة فرعية فقط من CSS مدعومة (لا
grid، لاfloat)
الخطوة 4: بناء رأس الفاتورة
أنشئ مكون الرأس الذي يعرض معلومات الشركة وبيانات الفاتورة التعريفية:
// 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}>
{/* اليسار: معلومات الشركة */}
<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 }}>
الرقم الضريبي: {data.company.taxId}
</Text>
)}
</View>
{/* اليمين: بيانات الفاتورة */}
<View>
<Text style={styles.invoiceTitle}>فاتورة</Text>
<Text style={styles.invoiceMeta}>#{data.invoiceNumber}</Text>
<Text style={styles.invoiceMeta}>التاريخ: {data.date}</Text>
<Text style={styles.invoiceMeta}>تاريخ الاستحقاق: {data.dueDate}</Text>
</View>
</View>
);
}الخطوة 5: بناء جدول العناصر
مكون الجدول يعرض كل عنصر في الفاتورة مع ألوان صفوف متناوبة:
// 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}>
{/* رأس الجدول */}
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, styles.descriptionCol]}>
الوصف
</Text>
<Text style={[styles.tableHeaderCell, styles.quantityCol]}>الكمية</Text>
<Text style={[styles.tableHeaderCell, styles.priceCol]}>
سعر الوحدة
</Text>
<Text style={[styles.tableHeaderCell, styles.totalCol]}>المجموع</Text>
</View>
{/* صفوف الجدول */}
{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>
);
}الخطوة 6: بناء تذييل الفاتورة مع الإجماليات
يعرض التذييل المجموع الفرعي والضريبة والمبلغ النهائي:
// 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 (
<>
{/* الإجماليات */}
<View style={styles.totalsContainer}>
<View style={styles.totalsBox}>
<View style={styles.totalsRow}>
<Text>المجموع الفرعي</Text>
<Text>{formatCurrency(data.subtotal, data.currency)}</Text>
</View>
<View style={styles.totalsRow}>
<Text>الضريبة ({data.taxRate}%)</Text>
<Text>{formatCurrency(data.taxAmount, data.currency)}</Text>
</View>
<View style={styles.totalsFinal}>
<Text style={styles.totalsFinalText}>الإجمالي</Text>
<Text style={styles.totalsFinalText}>
{formatCurrency(data.total, data.currency)}
</Text>
</View>
</View>
</View>
{/* الملاحظات */}
{data.notes && (
<View style={styles.notes}>
<Text style={styles.notesTitle}>ملاحظات</Text>
<Text style={styles.notesText}>{data.notes}</Text>
</View>
)}
{/* تذييل الصفحة */}
<View style={styles.footer} fixed>
<Text>
{data.company.name} | {data.company.email} | {data.company.phone}
</Text>
<Text style={{ marginTop: 2 }}>
شكراً لتعاملكم معنا
</Text>
</View>
</>
);
}خاصية fixed على View التذييل تعني أنه سيظهر في كل صفحة إذا امتدت الفاتورة لعدة صفحات.
الخطوة 7: تجميع مستند الفاتورة الكامل
الآن اجمع كل المكونات في المستند الرئيسي:
// 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={`فاتورة ${data.invoiceNumber}`}
author={data.company.name}
subject={`فاتورة لـ ${data.client.name}`}
creator="PDF Invoice App"
>
<Page size="A4" style={styles.page}>
{/* الرأس مع معلومات الشركة وبيانات الفاتورة */}
<InvoiceHeader data={data} />
{/* قسم الفوترة */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>فاتورة إلى</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>
{/* جدول العناصر */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>العناصر</Text>
<InvoiceTable items={data.items} currency={data.currency} />
</View>
{/* التذييل مع الإجماليات والملاحظات */}
<InvoiceFooter data={data} />
</Page>
</Document>
);
}يقبل مكون Document خصائص البيانات الوصفية (title، author، subject) التي يتم تضمينها في ملف PDF — وتظهر في لوحات معلومات قارئ PDF.
الخطوة 8: إنشاء مسار API لتوليد PDF
هنا يحدث السحر. أنشئ مسار API يقبل بيانات الفاتورة ويعيد تدفق 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();
// التحقق من الحقول المطلوبة
if (!data.invoiceNumber || !data.items?.length) {
return NextResponse.json(
{ error: "رقم الفاتورة وعنصر واحد على الأقل مطلوبان" },
{ status: 400 }
);
}
// توليد مخزن PDF المؤقت
const pdfBuffer = await renderToBuffer(
<InvoiceDocument data={data} />
);
// إرجاع PDF كملف قابل للتحميل
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:", error);
return NextResponse.json(
{ error: "فشل في توليد PDF" },
{ status: 500 }
);
}
}النقاط الرئيسية حول نقطة النهاية هذه:
renderToBufferتولد PDF بالكامل في الذاكرة — لا حاجة لنظام ملفات- Content-Disposition مع
attachmentيفعل تحميل المتصفح - Content-Type مضبوط على
application/pdfليتعامل المتصفح معه بشكل صحيح - يعمل على جانب الخادم في Node.js — يعمل على Vercel أو Railway أو أي مستضيف Node
الخطوة 9: بناء واجهة نموذج الفاتورة
أنشئ نموذج الواجهة الأمامية حيث يدخل المستخدمون بيانات الفاتورة:
// 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: "نقطة ديجيتال",
address: "123 شارع التكنولوجيا",
city: "تونس",
country: "تونس",
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: "الدفع مستحق خلال 30 يوماً. شكراً لتعاملكم معنا!",
};
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,
};
// إعادة حساب إجمالي العنصر
updatedItems[index].total =
updatedItems[index].quantity * updatedItems[index].unitPrice;
// إعادة حساب إجماليات الفاتورة
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");
}
// تحميل 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);
alert("فشل في توليد PDF. يرجى المحاولة مرة أخرى.");
} finally {
setIsGenerating(false);
}
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">مولد الفواتير</h1>
{/* معلومات العميل */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-semibold mb-4">معلومات العميل</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
placeholder="اسم العميل"
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="البريد الإلكتروني للعميل"
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="العنوان"
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="المدينة"
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="البلد"
className="border rounded px-3 py-2"
value={invoice.client.country}
onChange={(e) =>
setInvoice({
...invoice,
client: { ...invoice.client, country: e.target.value },
})
}
/>
</div>
</section>
{/* عناصر السطر */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-semibold mb-4">عناصر الفاتورة</h2>
<div className="space-y-3">
{invoice.items.map((item, index) => (
<div key={index} className="flex gap-3 items-center">
<input
type="text"
placeholder="الوصف"
className="border rounded px-3 py-2 flex-1"
value={item.description}
onChange={(e) =>
updateItem(index, "description", e.target.value)
}
/>
<input
type="number"
placeholder="الكمية"
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="سعر الوحدة"
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"
>
حذف
</button>
</div>
))}
</div>
<button
type="button"
onClick={addItem}
className="mt-4 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded"
>
+ إضافة عنصر
</button>
</section>
{/* الملخص */}
<section className="mb-8 p-6 border rounded-lg bg-gray-50">
<div className="flex justify-between mb-2">
<span>المجموع الفرعي</span>
<span>
{invoice.currency} {invoice.subtotal.toFixed(2)}
</span>
</div>
<div className="flex justify-between mb-2">
<span>الضريبة ({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>الإجمالي</span>
<span>
{invoice.currency} {invoice.total.toFixed(2)}
</span>
</div>
</section>
{/* زر التوليد */}
<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 ? "جاري توليد PDF..." : "تحميل فاتورة PDF"}
</button>
</div>
);
}الخطوة 10: إضافة دعم الخطوط المخصصة
الخطوط المضمنة (Helvetica، Courier، Times-Roman) تعمل للمحتوى الإنجليزي. للعربية والخطوط المخصصة أو الطباعة ذات العلامة التجارية، سجل خطوط TTF:
// src/lib/register-fonts.ts
import { Font } from "@react-pdf/renderer";
// تسجيل عائلة خط مخصصة
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,
},
],
});
// تسجيل خط يدعم العربية
Font.register({
family: "Noto Sans Arabic",
src: "https://fonts.gstatic.com/s/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyG2vu3CBFQLaig.ttf",
});استورد هذا الملف في أعلى مسار API لضمان تسجيل الخطوط قبل العرض:
// src/app/api/invoice/route.ts
import "@/lib/register-fonts";
// ... باقي المسارثم استخدم الخط المخصص في أنماطك:
page: {
fontFamily: "Inter",
// ... أنماط أخرى
},الخطوة 11: إضافة دعم RTL (العربية)
إحدى الميزات القوية لـ @react-pdf/renderer هي قدرتها على التعامل مع النص من اليمين إلى اليسار. هذا ضروري للفواتير العربية:
// 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>
{/* جدول بالاتجاه المعكوس */}
<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>
);
}التقنيات الرئيسية لـ RTL:
direction: "rtl"على نمط الصفحةflexDirection: "row-reverse"لعكس اتجاه التخطيطtextAlign: "right"لمحاذاة النص- عائلة خط عربي مسجلة مع
Font.register()
الخطوة 12: توليد PDF باستخدام Server Actions
لنهج أكثر تكاملاً، يمكنك استخدام Server Actions في Next.js بدلاً من مسار API منفصل:
// 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} />
);
// تحويل المخزن المؤقت إلى base64 لنقله إلى العميل
const base64 = Buffer.from(pdfBuffer).toString("base64");
return {
pdf: base64,
filename: `invoice-${data.invoiceNumber}.pdf`,
};
}استخدم Server Action على العميل:
// في مكون العميل الخاص بك
import { generateInvoicePDF } from "./actions";
async function handleGenerate() {
const { pdf, filename } = await generateInvoicePDF(invoice);
// فك ترميز base64 وتفعيل التحميل
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 مريحة للحالات البسيطة، لكن نهج مسار API أفضل عندما تحتاج لبث ملفات PDF كبيرة أو التكامل مع خدمات خارجية.
الخطوة 13: متقدم - معاينة PDF في المتصفح
لتجربة مستخدم أفضل، يمكنك عرض معاينة مباشرة قبل التحميل. استخدم pdf() لتوليد رابط 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">جاري إنشاء المعاينة...</p>
</div>
);
}
return (
<iframe
src={url}
className="w-full h-[600px] rounded-lg border"
title="معاينة الفاتورة"
/>
);
}مهم: دالة pdf() من @react-pdf/renderer تعمل بالكامل في المتصفح. هذا يعني أن المعاينة تعمل على جانب العميل بدون الوصول إلى خادمك — ممتاز للحصول على ردود فعل فورية أثناء ملء النموذج.
الخطوة 14: توليد PDF دفعي
لتوليد فواتير متعددة مرة واحدة (فوترة شهرية، تقارير)، أنشئ نقطة نهاية دفعية:
// 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: "لم يتم تقديم فواتير" },
{ status: 400 }
);
}
// توليد جميع ملفات PDF بشكل متزامن
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);
// إنشاء أرشيف 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="invoices-${Date.now()}.zip"`,
},
});
}ثبت تبعية archiver:
npm install archiver
npm install -D @types/archiverاختبار التنفيذ
شغل خادم التطوير واختبر مولد الفواتير:
npm run devانتقل إلى http://localhost:3000/invoice ثم:
- املأ تفاصيل العميل — أدخل الاسم والبريد الإلكتروني والعنوان
- أضف عناصر — أضف عناصر متعددة بكميات وأسعار
- تحقق من الحسابات — تأكد من صحة المجموع الفرعي والضريبة والإجمالي
- ولد PDF — انقر على زر التحميل
- افتح ملف PDF — تحقق من أن التخطيط والخطوط والبيانات مطابقة
يمكنك أيضاً اختبار API مباشرة باستخدام 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": "نقطة ديجيتال",
"address": "123 شارع التكنولوجيا",
"city": "تونس",
"country": "تونس",
"email": "billing@noqta.tn",
"phone": "+216 71 000 000"
},
"client": {
"name": "شركة أكمي",
"address": "456 شارع الأعمال",
"city": "دبي",
"country": "الإمارات",
"email": "accounts@acme.com"
},
"items": [
{"description": "تطوير ويب", "quantity": 40, "unitPrice": 45, "total": 1800},
{"description": "تصميم UI/UX", "quantity": 20, "unitPrice": 50, "total": 1000}
],
"subtotal": 2800,
"taxRate": 19,
"taxAmount": 532,
"total": 3332,
"currency": "TND",
"notes": "الدفع مستحق خلال 30 يوماً."
}' \
--output invoice.pdfاستكشاف الأخطاء وإصلاحها
المشاكل الشائعة والحلول
"Cannot find module @react-pdf/renderer"
يحدث هذا عادة عندما يحاول Next.js تجميع React-PDF للعميل. تأكد من أنك تستورد renderToBuffer فقط في ملفات جانب الخادم (مسارات API، Server Actions). استخدم الاستيراد الديناميكي إذا لزم الأمر:
const { renderToBuffer } = await import("@react-pdf/renderer");أخطاء "Invalid hook call"
مكونات React-PDF ليست مكونات React DOM. لا تعرضها أبداً داخل شجرة مكونات React عادية. يمكن فقط تمريرها إلى renderToBuffer أو renderToStream أو pdf().
الخطوط المخصصة لا تحمل
يجب أن تكون روابط الخطوط قابلة للوصول في وقت البناء/العرض. للخطوط المستضافة ذاتياً، ضعها في مجلد public/ واستخدم روابط مطلقة:
Font.register({
family: "MyFont",
src: "/fonts/MyFont-Regular.ttf",
});ملف PDF فارغ أو خالي
تأكد من أن بياناتك ليست undefined. أضف console logs في مسار API للتحقق من وصول البيانات إلى دالة العرض. تأكد أيضاً من أن محتوى النص ملفوف في مكونات <Text> — النصوص المجردة داخل <View> لن تُعرض.
ملفات PDF الكبيرة بطيئة
للمستندات ذات الصفحات الكثيرة، استخدم renderToStream بدلاً من renderToBuffer لتجنب تحميل ملف PDF بالكامل في الذاكرة:
import { renderToStream } from "@react-pdf/renderer";
const stream = await renderToStream(<InvoiceDocument data={data} />);
return new NextResponse(stream as any, {
headers: { "Content-Type": "application/pdf" },
});نصائح الأداء
- خزن الخطوط مؤقتاً — سجل الخطوط مرة واحدة على مستوى الوحدة، ليس لكل طلب
- استخدم
renderToStreamللمستندات الأكبر من 50 صفحة - قلل إعادة العرض في مكون المعاينة باستخدام
useMemo - احسب الإجماليات مسبقاً على الخادم بدلاً من مكونات PDF
- اضغط الصور قبل تضمينها في ملفات PDF (استخدم WebP أو PNG مضغوط)
الخطوات التالية
الآن بعد أن أصبح لديك نظام إنشاء PDF يعمل، فكر في هذه التحسينات:
- تكامل البريد الإلكتروني — أرسل الفواتير كمرفقات بريد إلكتروني مع Resend
- تخزين قاعدة البيانات — احفظ بيانات الفاتورة مع Drizzle ORM أو Prisma
- المصادقة — احمِ نقطة نهاية الفاتورة مع Better Auth
- التقارير المجدولة — أنشئ تقارير شهرية مع Trigger.dev
- القوالب — أنشئ قوالب للتقارير والإيصالات والشهادات
- التوقيعات الرقمية — أضف توقيع PDF للامتثال القانوني
الخلاصة
لقد بنيت نظام إنشاء PDF متكامل باستخدام @react-pdf/renderer و Next.js 15. الإعداد متوافق مع serverless، وآمن الأنواع، ويستخدم أنماط مكونات React المألوفة.
النقاط الرئيسية هي:
- @react-pdf/renderer تولد ملفات PDF بمكونات React — لا حاجة لمتصفح بدون واجهة
- تخطيط Flexbox يجعل التموضع طبيعياً ومتوقعاً
- مسارات API توفر نقطة نهاية نظيفة لتوليد وتحميل PDF
- Server Actions تقدم بديلاً أبسط لإنشاء PDF داخل التطبيق
- الخطوط المخصصة ودعم RTL تجعلها مناسبة للمستندات متعددة اللغات
- البث يتيح توليد المستندات الكبيرة بكفاءة
ملفات PDF هي متطلب أساسي لتطبيقات الأعمال — الفواتير والتقارير والعقود والشهادات. مع هذا الأساس، يمكنك بناء أي قالب مستند يحتاجه تطبيقك.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

بناء تطبيق متكامل باستخدام Drizzle ORM و Next.js 15: قاعدة بيانات آمنة الأنواع من الصفر إلى الإنتاج
تعلّم كيفية بناء تطبيق متكامل آمن الأنواع باستخدام Drizzle ORM مع Next.js 15. يغطي هذا الدليل العملي تصميم المخططات والترحيلات وServer Actions وعمليات CRUD والنشر مع PostgreSQL.

بناء تطبيق متكامل باستخدام Prisma ORM و Next.js 15 App Router
تعلم كيفية بناء تطبيق متكامل باستخدام Prisma ORM و Next.js 15 App Router و PostgreSQL. يغطي هذا الدليل نمذجة المخطط والترحيلات و Server Actions وعمليات CRUD والعلاقات والنشر في بيئة الإنتاج.

Zod v4 مع Next.js 15: دليل شامل للتحقق من البيانات في النماذج وواجهات API و Server Actions
تعلم استخدام Zod v4 في Next.js 15 — تحقق من النماذج باستخدام Server Actions، أمّن مسارات API، تحقق من متغيرات البيئة، وابنِ تطبيقات آمنة الأنواع بالكامل مع أسرع مكتبة للتحقق في TypeScript.