هل تعتقد أنك تعرف App Router؟ جرب هذا: لماذا يتجمد تطبيقك عند استخدام generateStaticParams مع 50 ألف مسار؟ وكيف يحل React Server Components مشكلة الـ I/O Bound التي تقتل أداء السيرفر؟ هذا الدليل يفكك App Router من الذاكرة إلى المعالج، مع أكواد حقيقية وحلول للمشاكل التي لا تجدها في التوثيق الرسمي.
في صيف ٢٠٢٤، قررت شركة ناشئة في دبي ترحيل تطبيقها من Pages Router إلى App Router. بعد أسبوعين من العمل، اكتشف الفريق أن الـ Time to First Byte ارتفع من ٨٠ مللي إلى ٤٥٠ مللي. السبب؟ استخدموا Server Components بالطريقة الخاطئة، فتحول كل طلب إلى سلسلة من الـ blocking I/O calls. هذه ليست قصة، بل واقع يواجهه كل مطور يتعامل مع App Router دون فهم عميق لكيفية عمله خلف الكواليس. في ٢٠٢٥، App Router ليس مجرد ميزة جديدة، بل هو الأساس الذي يبني عليه Next.js مستقبله، ومعظم المطورين يستخدمونه بطريقة سطحية دون استغلال ٢٠٪ من ميزاته التي تحل ٨٠٪ من مشاكل الأداء.
هذا الدليل ليس ترجمة للتوثيق الرسمي، بل هو تشريح عملي لما يحدث تحت غطاء المحرك. سنبدأ بفهم الفرق بين Server Components وClient Components من منظور الـ Event Loop والذاكرة، ثم ننتقل إلى كيفية تعامل Next.js مع الـ Caching على مستوى الـ CDN والـ Edge، وبعدها سنغوص في المشاكل الحقيقية التي تواجهها الفرق عند استخدام dynamic routes مع قواعد بيانات ضخمة. كل قسم مدعوم بأكواد حقيقية قابلة للتنفيذ، وحلول للمشاكل التي لا تجدها في الأمثلة التافهة التي تنتشر على الإنترنت.
عندما تسمع أن Server Components تُنفذ على السيرفر، قد تظن أن الأمر بسيط مثل PHP القديم. لكن الحقيقة أعقد بكثير. في Next.js، Server Component هو عبارة عن دالة React تُحول إلى JSON قبل إرسالها إلى المتصفح. هذا يعني أن المتصفح لا يرى أبداً الكود الأصلي، بل يرى فقط النتيجة النهائية كـ props. لماذا هذا مهم؟ لأنك تستطيع تشغيل مكتبات ضخمة مثل Prisma أو Sharp داخل Server Component دون أن تزيد حجم حزمة الجافاسكريبت التي تُرسل إلى العميل. في مشروع حقيقي عملت عليه، قللنا حجم الحزمة من ١.٢ ميجابايت إلى ٣٥٠ كيلوبايت فقط بنقل مكونات الجداول الكبيرة إلى Server Components.
لكن هناك فخ كبير هنا: إذا استخدمت Server Component بطريقة خاطئة، ستحول تطبيقك إلى سلسلة من الـ blocking calls. مثلاً، هذا الكود يبدو بريئاً لكنه كارثة:
// app/page.tsx
async function getData() {
const res = await fetch('https://api.example.com/data'); // blocking I/O
return res.json();
}
export default async function Page() {
const data = await getData(); // هذا الانتظار يجمد الـ Event Loop
return <div>{data.title}</div>;
}المشكلة هنا أن await داخل Server Component يوقف تنفيذ باقي الكود حتى ينتهي الطلب. في سيرفر Node.js، هذا يعني أن الـ Event Loop سيتجمد، وكل المستخدمين الآخرين سينتظروا. الحل؟ استخدم Promise.all أو قم بتفكيك المكونات إلى أجزاء أصغر:
// app/page.tsx
export default async function Page() {
const [data, user] = await Promise.all([
fetch('https://api.example.com/data').then(res => res.json()),
fetch('https://api.example.com/user').then(res => res.json())
]);
return <Dashboard data={data} user={user} />;
}من تجربتي، معظم الفرق التي تعاني من بطء في تطبيقات Next.js تستخدم Server Components بالطريقة الخاطئة. القاعدة الذهبية: إذا كان الكود يعتمد على I/O (قواعد بيانات، APIs خارجية)، فافصله إلى مكونات صغيرة واستخدم Promise.all. وإذا كان الكود يعتمد على الـ CPU (معالجة صور، حسابات معقدة)، فافصله إلى Client Component واستخدم Web Workers.
في React 19، تم تقديم hook جديد اسمه use يمكنه التعامل مع Promises داخل Client Components. هذا مفيد جداً عندما تريد جلب بيانات داخل مكون تفاعلي دون تحويله إلى Server Component. مثلاً، هذا الكود يعمل بشكل رائع:
'use client';
import { use } from 'react';
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}لكن هناك مشكلة خفية هنا: إذا تغير userId، سيقوم React بإعادة تنفيذ fetchUser دون إلغاء الطلب السابق. هذا قد يؤدي إلى race conditions. الحل؟ استخدم مكتبة مثل SWR أو React Query التي تدير الـ cache والـ revalidation تلقائياً. في رأيي، use هو أداة قوية لكنها تحتاج إلى تعامل حذر، خاصة في التطبيقات الكبيرة حيث قد يكون هناك عشرات الطلبات المتزامنة.
في Pages Router، كان الـ caching يعتمد على getStaticProps وgetServerSideProps. في App Router، النظام أكثر ذكاءً ومرونة، لكنه أيضاً أكثر تعقيداً. هناك أربعة مستويات من الـ caching تعمل معاً: Request Memoization،Data Cache،Full Route Cache، وRouter Cache. المشكلة أن معظم المطورين لا يفهمون الفرق بينها، مما يؤدي إلى سلوك غير متوقع.
لنبدأ بـ Request Memoization. هذه الميزة تجعل Next.js يتذكر نتائج الـ fetch requests داخل نفس الـ request lifecycle. مثلاً، إذا قمت باستدعاء نفس الـ API مرتين داخل نفس الصفحة، سيتم تنفيذ الطلب مرة واحدة فقط. هذا مفيد جداً في التطبيقات التي تستخدم GraphQL حيث قد يكون هناك عدة استدعاءات لنفس البيانات. لكن هناك فخ هنا: هذا الـ cache لا يعمل عبر صفحات مختلفة. إذا ذهبت من الصفحة A إلى الصفحة B واستخدمت نفس الـ API، سيتم تنفيذ الطلب مرة أخرى.
Data Cache هو المستوى الثاني، وهو مسؤول عن تخزين نتائج الـ fetch requests عبر طلبات مختلفة. يمكنك التحكم فيه باستخدام خيارات الـ fetch:
fetch('https://api.example.com/data', {
next: { revalidate: 60 } // إعادة التحقق كل ٦٠ ثانية
});
// أو لتعطيل الـ cache تماماً
fetch('https://api.example.com/data', { cache: 'no-store' });لكن هناك مشكلة شائعة هنا: إذا استخدمت dynamic routes مع generateStaticParams، قد تجد أن بعض الصفحات لا تُحدث بياناتها كما تتوقع. مثلاً، في مشروع e-commerce عملت عليه، استخدمنا generateStaticParams لإنشاء صفحات المنتجات، لكن عند تحديث سعر المنتج، لم يتم تحديث الصفحة إلا بعد إعادة تشغيل السيرفر. الحل؟ استخدم revalidatePath أو revalidateTag:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { path } = await request.json();
revalidatePath(path);
return NextResponse.json({ revalidated: true });
}في رأيي، معظم المشاكل التي تواجهها الفرق مع App Router تأتي من سوء فهم نظام الـ caching. القاعدة الذهبية: إذا كانت بياناتك تتغير بشكل متكرر، استخدم revalidate مع قيمة صغيرة (مثل ٥ ثوانٍ)، وإذا كانت بياناتك ثابتة، استخدم static generation مع revalidate كبير. ولا تعتمد أبداً على الـ cache الافتراضي، بل حدد سلوكه بشكل صريح.
في Pages Router، كان التعامل مع dynamic routes بسيطاً: استخدم getStaticPaths مع fallback. في App Router، النظام أكثر تعقيداً لكنه أيضاً أكثر قوة. المشكلة الأكبر التي تواجهها الفرق هي عندما يكون لديك عدد كبير من المسارات، مثل موقع e-commerce به ٥٠ ألف منتج. إذا استخدمت generateStaticParams بالطريقة الخاطئة، قد ينتهي بك الأمر بتوليد ٥٠ ألف ملف HTML، مما يستهلك مساحة تخزين هائلة ويبطئ عملية البناء.
الحل؟ استخدم مزيجاً من generateStaticParams مع fallback:true وrevalidate. مثلاً، يمكنك توليد الصفحات الأكثر زيارة فقط، وترك الباقي ليتم توليدها عند الطلب:
// app/products/[slug]/page.tsx
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products?limit=100');
const products = await res.json();
return products.map((product: any) => ({ slug: product.slug }));
}
export const dynamicParams = true; // يسمح بمسارات غير موجودة في generateStaticParams
export default async function Page({ params }: { params: { slug: string } }) {
const product = await fetch(`https://api.example.com/products/${params.slug}`).then(res => res.json());
return <ProductPage product={product} />;
}لكن هناك مشكلة هنا: إذا كان لديك ٥٠ ألف منتج، قد يستغرق توليد أول ١٠٠ صفحة وقتاً طويلاً. الحل؟ استخدم parallel fetching مع Promise.all لتقليل وقت البناء:
export async function generateStaticParams() {
const [topProducts, newProducts] = await Promise.all([
fetch('https://api.example.com/products?sort=popular&limit=50').then(res => res.json()),
fetch('https://api.example.com/products?sort=new&limit=50').then(res => res.json())
]);
return [...topProducts, ...newProducts].map((product: any) => ({ slug: product.slug }));
}من تجربتي، معظم الفرق التي تستخدم generateStaticParams تختار إما توليد كل المسارات (مما يؤدي إلى وقت بناء طويل) أو عدم توليد أي شيء (مما يؤدي إلى بطء في التحميل). الحل الأمثل هو توليد المسارات الأكثر زيارة فقط، واستخدام fallback:true للباقي. أيضاً، لا تنسَ استخدام revalidatePath عند تحديث البيانات لتجنب توليد صفحات قديمة.
في مشروع عملت عليه، كان لدينا مسارات مثل /categories/[category]/products/[product]. المشكلة أن generateStaticParams لا يدعم المسارات المتداخلة بسهولة. الحل؟ استخدم مزيجاً من generateStaticParams مع dynamicParams:
// app/categories/[category]/products/[product]/page.tsx
export async function generateStaticParams() {
const categories = await fetch('https://api.example.com/categories').then(res => res.json());
const params = [];
for (const category of categories) {
const products = await fetch(`https://api.example.com/categories/${category.slug}/products?limit=10`).then(res => res.json());
for (const product of products) {
params.push({ category: category.slug, product: product.slug });
}
}
return params;
}
export const dynamicParams = true;
export default async function Page({ params }: { params: { category: string, product: string } }) {
// ...
}لكن هذا الكود قد يكون بطيئاً جداً إذا كان لديك مئات الفئات. الحل؟ استخدم parallel fetching مع Promise.all لتقليل وقت البناء. أيضاً، يمكنك استخدام revalidateTag لإعادة التحقق من البيانات عند تحديثها:
fetch(`https://api.example.com/categories/${category.slug}/products`, {
next: { tags: [`category-${category.slug}`] }
});
// ثم عند تحديث البيانات
revalidateTag(`category-${category.slug}`);في Next.js 14، تم تحسين Edge Runtime بشكل كبير، وأصبح خياراً جذاباً للتطبيقات التي تحتاج إلى أداء عالٍ جداً. لكن هناك الكثير من سوء الفهم حول متى يجب استخدامه وكيفية التعامل مع قيوده. أولاً، دعنا نفهم ما هو Edge Runtime بالضبط: إنه بيئة تنفيذ تعتمد على V8 isolates بدلاً من Node.js، مما يجعلها أخف وزناً وأسرع في بدء التشغيل. هذا مفيد جداً للتطبيقات التي تحتاج إلى زمن استجابة منخفض، مثل APIs التي تُستخدم من قبل تطبيقات الموبايل.
لكن هناك قيود كبيرة: Edge Runtime لا يدعم كل مكتبات Node.js. مثلاً، لا يمكنك استخدام fs أو child_process. أيضاً، لا يمكنك استخدام مكتبات تعتمد على Node.js APIs مثل Prisma أو Sharp. في مشروع عملت عليه، حاولنا استخدام Edge Runtime مع Prisma، فقط لنكتشف أن التطبيق يتوقف عن العمل تماماً. الحل؟ استخدم مزيجاً من Edge وServerless:
// app/api/edge/route.ts
export const runtime = 'edge';
export async function GET() {
const data = await fetch('https://api.example.com/data').then(res => res.json());
return Response.json(data);
}لكن حتى مع هذه القيود، Edge Runtime يمكن أن يكون مفيداً جداً في حالات معينة. مثلاً، إذا كنت تبني API بسيط يستجيب بسرعة كبيرة، أو إذا كنت تريد تقليل زمن الاستجابة للمستخدمين في مناطق جغرافية مختلفة. أيضاً، Edge Runtime يدعم WebAssembly، مما يفتح الباب أمام استخدام مكتبات مثل TensorFlow.js في بيئة Edge.
في Edge Runtime، يمكنك استخدام الـ Streaming لإرسال البيانات إلى العميل قبل اكتمال المعالجة. هذا مفيد جداً للتطبيقات التي تحتاج إلى عرض البيانات بشكل تدريجي، مثل لوحات التحكم التي تعرض بيانات في الوقت الفعلي. مثلاً، هذا الكود يرسل بيانات بشكل متدفق:
// app/api/stream/route.ts
export const runtime = 'edge';
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = await fetch(`https://api.example.com/data/${i}`).then(res => res.json());
controller.enqueue(new TextEncoder().encode(JSON.stringify(data)));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' }
});
}لكن هناك مشكلة هنا: إذا حدث خطأ في منتصف الـ stream، قد لا يتم إغلاق الـ stream بشكل صحيح، مما يؤدي إلى تسرب الموارد. الحل؟ استخدم try/catch مع finally لإغلاق الـ stream في جميع الحالات:
try {
// الكود الذي قد يحدث فيه خطأ
} catch (error) {
controller.error(error);
} finally {
controller.close();
}في رأيي، Edge Runtime هو أداة قوية لكنها تحتاج إلى فهم عميق لقيودها. إذا كنت تبني تطبيقاً بسيطاً يعتمد على APIs خارجية ولا يحتاج إلى مكتبات Node.js، فقد يكون Edge Runtime خياراً جيداً. لكن إذا كنت بحاجة إلى مكتبات مثل Prisma أو Sharp، فاستخدم Serverless Runtime بدلاً من ذلك.
في Next.js 14، تم تقديم Server Actions كطريقة جديدة للتعامل مع الـ mutations دون الحاجة إلى كتابة APIs منفصلة. الفكرة بسيطة: بدلاً من كتابة API route ثم استدعائه من الـ client، يمكنك كتابة دالة مباشرة داخل مكون React، وتستخدمها كـ action في form أو button. هذا يبدو رائعاً في البداية، لكنه يأتي مع مجموعة من المشاكل والتحديات.
لنبدأ بالمزايا: Server Actions تقلل من الكود الذي تكتبه بشكل كبير. مثلاً، بدلاً من كتابة API route ثم استدعائه من الـ client، يمكنك كتابة دالة واحدة:
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const c formData.get('content') as string;
// حفظ البيانات في قاعدة البيانات
await db.post.create({ data: { title, content } });
}// app/page.tsx
import { createPost } from './actions';
export default function Page() {
return (
<form action={createPost}>
<input type="text" name="title" />
<textarea name="content" />
<button type="submit">Create Post</button>
</form>
);
}هذا يبدو رائعاً، لكنه يأتي مع مشاكل كبيرة. أولاً، Server Actions تعمل فقط مع forms. إذا كنت تريد استخدامها مع حدث مثل onClick، ستحتاج إلى استخدام useFormState أو useFormStatus من React. ثانياً، هناك مشكلة كبيرة مع الـ revalidation: إذا قمت بتحديث البيانات باستخدام Server Action، لن يتم تحديث الصفحة تلقائياً إلا إذا استخدمت revalidatePath أو revalidateTag. ثالثاً، هناك مشكلة أمنية محتملة: إذا لم تتحقق من البيانات بشكل صحيح، قد تكون عرضة لهجمات مثل CSRF.
في المثال السابق، إذا قمت بإنشاء post جديد، لن يتم تحديث قائمة الـ posts تلقائياً. الحل؟ استخدم revalidatePath:
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const c formData.get('content') as string;
await db.post.create({ data: { title, content } });
revalidatePath('/posts'); // إعادة تحميل صفحة الـ posts
}لكن هناك مشكلة هنا: إذا كان لديك عدة صفحات تعرض نفس البيانات، قد تحتاج إلى إعادة تحميل كل صفحة على حدة. الحل؟ استخدم revalidateTag بدلاً من revalidatePath:
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const c formData.get('content') as string;
await db.post.create({ data: { title, content } });
revalidateTag('posts'); // إعادة تحميل جميع الصفحات التي تستخدم هذا الـ tag
}من تجربتي، Server Actions هي أداة قوية لكنها ليست بديلاً كاملاً لـ APIs. إذا كنت تبني تطبيقاً بسيطاً، فقد تكون Server Actions كافية. لكن إذا كنت بحاجة إلى تحكم أكبر في الـ requests والـ responses، أو إذا كنت تريد استخدام نفس الـ API من تطبيق الموبايل، فاستخدم API routes بدلاً من ذلك. أيضاً، لا تنسَ التحقق من البيانات بشكل صحيح لتجنب المشاكل الأمنية.
في Pages Router، كان التعامل مع الـ Middleware بسيطاً: استخدم ملف _middleware.js. في App Router، النظام أكثر قوة ومرونة، لكنه أيضاً أكثر تعقيداً. الـ Middleware في Next.js يعمل على مستوى الـ Edge، مما يعني أنه سريع جداً، لكنه يأتي مع قيود: لا يمكنك استخدام مكتبات Node.js، ولا يمكنك الوصول إلى الـ filesystem. هذا يجعله مثالياً للمهام البسيطة مثل إعادة التوجيه والتحقق من الـ authentication، لكنه ليس مناسباً للمهام المعقدة.
لنبدأ بمثال بسيط: إعادة توجيه المستخدمين غير المسجلين إلى صفحة تسجيل الدخول:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token && !request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const c {
matcher: ['/dashboard/:path*']
};هذا الكود يعمل بشكل جيد، لكنه يأتي مع مشكلة: إذا كان لديك عدة مسارات تحتاج إلى حماية، قد ينتهي بك الأمر بكود متكرر. الحل؟ استخدم مزيجاً من الـ matcher والـ conditions:
export const c {
matcher: [
'/dashboard/:path*',
'/profile',
'/settings'
]
};
export function middleware(request: NextRequest) {
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
const token = request.cookies.get('auth-token')?.value;
if (!token && protectedRoutes.some(route => request.nextUrl.pathname.startsWith(route))) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}لكن هناك مشكلة أكبر هنا: إذا كنت تستخدم JWT للتحقق من الـ authentication، قد تجد أن الـ Middleware لا يستطيع التحقق من صحة الـ token لأنه لا يدعم مكتبات مثل jsonwebtoken. الحل؟ استخدم مكتبة خفيفة مثل jose التي تعمل في بيئة Edge:
import { jwtVerify } from 'jose';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET!));
// يمكنك الآن الوصول إلى payload.userId مثلاً
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}من تجربتي، الـ Middleware في Next.js هو أداة قوية لكنها تحتاج إلى فهم عميق لقيودها. إذا كنت بحاجة إلى القيام بمهام معقدة مثل معالجة الصور أو التفاعل مع قواعد البيانات، فاستخدم API routes بدلاً من ذلك. أيضاً، لا تنسَ أن الـ Middleware يعمل على كل طلب، لذا احرص على جعله خفيفاً وسريعاً لتجنب التأثير على الأداء.
في مشروع عملت عليه، كنا بحاجة إلى تنفيذ A/B Testing على مستوى الـ Middleware. الفكرة بسيطة: بناءً على قيمة معينة في الـ cookie، نعيد توجيه المستخدم إلى نسخة مختلفة من الصفحة. لكن هناك مشكلة: إذا قمت بإعادة التوجيه بشكل عشوائي، قد ينتهي بك الأمر بمستخدمين يرون نسخاً مختلفة في كل طلب، مما يؤدي إلى تجربة غير متسقة. الحل؟ استخدم قيمة ثابتة في الـ cookie:
export function middleware(request: NextRequest) {
const abTestCookie = request.cookies.get('ab-test')?.value;
if (!abTestCookie) {
// إذا لم يكن هناك قيمة في الـ cookie، اختر نسخة عشوائياً
const version = Math.random() > 0.5 ? 'A' : 'B';
const resp NextResponse.next();
response.cookies.set('ab-test', version, { maxAge: 60 * 60 * 24 * 30 }); // لمدة شهر
return response;
}
// إذا كان المستخدم في النسخة B، أعد توجيهه إلى المسار المناسب
if (abTestCookie === 'B' && request.nextUrl.pathname.startsWith('/home')) {
return NextResponse.rewrite(new URL('/home-b', request.url));
}
return NextResponse.next();
}هذا الكود يضمن أن المستخدم يرى نفس النسخة في كل طلب، مما يوفر تجربة متسقة. أيضاً، يمكنك استخدام نفس الأسلوب لتنفيذ ميزات تجريبية أو لإظهار محتوى مختلف بناءً على الموقع الجغرافي للمستخدم.
بعد أكثر من عامين من العمل مع App Router في مشاريع حقيقية، هذه هي النصائح العملية التي أتمنى أن أعرفها منذ البداية:
في النهاية، App Router ليس مجرد ميزة جديدة في Next.js، بل هو تغيير جذري في كيفية بناء التطبيقات. إذا فهمته جيداً، ستتمكن من بناء تطبيقات أسرع وأكثر كفاءة. وإذا استخدمته بالطريقة الخاطئة، ستجد نفسك تقاتل مع مشاكل الأداء والأخطاء الغامضة. المفتاح هو الفهم العميق لكيفية عمله خلف الكواليس، والتجربة المستمرة مع الأكواد الحقيقية.
خطوتك التالية؟ جرب إعادة بناء تطبيق صغير باستخدام App Router، وحاول تطبيق كل المفاهيم التي تعلمتها هنا. لا تكتفِ بالأمثلة البسيطة، بل جرب السيناريوهات المعقدة مثل dynamic routes مع قواعد بيانات ضخمة، وServer Components مع مكتبات ضخمة، وEdge Runtime مع الـ Streaming. ، ستتمكن من إتقان App Router حقاً.