السلسلة: هذا الدرس جزء من سلسلة React 19 وNext.js 15. للحصول على نظرة شاملة، اقرأ المقال الرئيسي.
App Router هو القطيعة المفاهيمية الكبرى التي قدّمها Next.js 13 ثم استقرّت ومُدّت في فرع 15. بدل تعريف كل route بـ JSX أو عبر إعداد مركزي، نُعلن شجرة التطبيق مباشرة عبر بنية ملفات مجلد app/. اسم مجلد يصير مقطع URL. ملف باسم اصطلاحي (page.tsx، layout.tsx، loading.tsx) يصير سلوكاً محدّداً لهذا المقطع. النتيجة نظام قوي يجعل الكود موثَّقاً ذاتياً تقريباً.
المتطلبات
- Node.js 20 LTS أو أحدث
- مشروع Next.js 15 مهيّأ مع App Router
- فهم حدّ Server/Client Components
- 75-100 دقيقة
pnpm create next-app@latest boutique --typescript --tailwind --app --turbopack --src-dir --no-linter
الخطوة 1 — فهم أسماء الملفات الخاصة السبعة
| الملف | الدور |
|---|---|
page.tsx |
يعرّف UI المعروض على URL المقطع. بدون هذا الملف، المقطع غير متاح. |
layout.tsx |
قالب دائم يغلّف الصفحات الأبناء. محفوظ في الذاكرة بين التنقلات. |
loading.tsx |
UI معروضة خلال تحميل المقطع (Suspense آلي). |
error.tsx |
UI معروضة إن طُلقت خطأ في الشجرة الفرعية (Error Boundary). |
not-found.tsx |
UI معروضة إن نُودي notFound() في المقطع. |
template.tsx |
مثل layout.tsx لكن يُعاد تركيبه عند كل تنقل (يفقد حالته). |
route.ts |
يعرّف Route Handler HTTP. لا يتعايش مع page.tsx في نفس المجلد. |
الخطوة 2 — layout الجذر والاصطلاحات الأساسية
app/layout.tsx إلزامي. هو الـ layout الوحيد الذي يجب أن يحوي وسومي <html> و<body>.
// src/app/layout.tsx
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: { default: 'Boutique Aria', template: '%s | Boutique Aria' },
description: 'Casques et accessoires audio livres en 48 heures.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className="min-h-screen bg-white text-gray-900">
<header className="border-b p-4">
<a href="/" className="text-xl font-bold">Boutique Aria</a>
</header>
<main className="max-w-5xl mx-auto p-6">{children}</main>
</body>
</html>
);
}
كائن metadata المُصدَّر هو API Next.js 15 الرسمي لإدارة وسوم <title> و<meta> وOpenGraph وTwitter Card. template يسمح بعرض Catalogue | Boutique Aria على الصفحات الأبناء. يستبدل تماماً next/head.
الخطوة 3 — المقاطع الديناميكية وcatch-all
// src/app/produits/[slug]/page.tsx
export default async function FicheProduit({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <h1>Produit : {slug}</h1>;
}
تفصيل مهمّ: params أصبح Promise في Next.js 15، لا كائن مباشر. يجب await params. codemod npx @next/codemod@latest upgrade يُهاجر آلياً.
لمطابقة عدة مقاطع، بنية catch-all: app/docs/[...slug]/page.tsx يلتقط /docs/intro وأيضاً /docs/api/v2/auth كاشفاً slug كمصفوفة ['api', 'v2', 'auth']. متغيّر [[...slug]] يجعل المعامل اختيارياً.
الخطوة 4 — Loading وError UI
// src/app/produits/[slug]/loading.tsx
export default function LoadingProduit() {
return (
<div className="animate-pulse">
<div className="h-8 w-2/3 bg-gray-200 rounded"></div>
<div className="h-4 w-1/3 bg-gray-200 rounded mt-3"></div>
</div>
);
}
Next.js يلفّ تلقائياً page.tsx في <Suspense> ويستخدم هذا الـ loading.tsx كـ fallback. ما لم يُحلّ await getProduit(slug)، skeleton يُعرَض.
// src/app/produits/[slug]/error.tsx
'use client';
export default function ErreurProduit({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-6 border border-red-200 rounded bg-red-50">
<h2 className="font-bold text-red-800">خطأ تحميل</h2>
<p className="text-sm text-red-700 mt-2">{error.message}</p>
<button onClick={reset}
className="mt-4 px-3 py-1 border border-red-300 rounded">
أعد المحاولة
</button>
</div>
);
}
Error Boundary يلتقط أي استثناء من مكوّنات خادم أو عميل في الشجرة الفرعية. للأخطاء الفادحة في layout الجذر، يجب app/global-error.tsx الذي يجب أن يضمّ <html> و<body>.
الخطوة 5 — Route groups والتنظيم
حين تكبر الشجرة، نحتاج لتجميع routes دون أن تظهر في URL. بنية (nom) بالأقواس تنشئ route group.
src/app/
├── (marketing)/
│ ├── layout.tsx // header بسيط
│ ├── page.tsx // /
│ ├── about/
│ │ └── page.tsx // /about
│ └── contact/
│ └── page.tsx // /contact
├── (boutique)/
│ ├── layout.tsx // header مع سلّة
│ ├── produits/
│ │ ├── page.tsx // /produits
│ │ └── [slug]/
│ │ └── page.tsx // /produits/[slug]
│ └── panier/
│ └── page.tsx // /panier
└── layout.tsx // layout جذر مشترك
URLs لا تحتوي (marketing) ولا (boutique) — فقط المقاطع الفعلية. لكن كل مجموعة لها layout.tsx خاص.
الخطوة 6 — Routes موازية ومعترِضة
parallel route تُعرَّف بمجلد مسبوق بـ @، مثل @modal. تُعرَض موازية للصفحة الرئيسة.
// src/app/(boutique)/layout.tsx
export default function BoutiqueLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
مدمجة مع intercepting route (بادئة (.)، (..) أو (...))، يمكن اعتراض تنقل وعرض modale بدل مغادرة الصفحة. الحالة الكلاسيكية: شبكة منتجات، نقر منتج يفتح modale لكن URL يصير /produits/abc؛ تحديث الصفحة يعرض البطاقة كاملة الشاشة.
src/app/(boutique)/
├── @modal/
│ ├── default.tsx // null افتراضياً
│ └── (.)produits/[slug]/page.tsx // يعترض /produits/[slug]
├── produits/
│ ├── page.tsx
│ └── [slug]/
│ └── page.tsx // البطاقة كاملة
└── layout.tsx
ملف default.tsx إلزامي في كل parallel route. بدونه، تحديث مباشر يكسر العرض.
الخطوة 7 — Middleware وحماية routes
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
const isProtected = request.nextUrl.pathname.startsWith('/compte');
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/compte/:path*', '/admin/:path*'],
};
الـ matcher يتجنّب تشغيل middleware لكل الطلبات. منذ Next.js 15.5، runtime Node.js مستقرّ للـ middlewares — مفيد إن احتجنا استدعاء قاعدة عبر TCP بدل APIs المتوافقة مع Edge فقط.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| «params should be awaited before using its properties» | كود مكتوب لـ Next.js 14 | أضف await وجعل الدالة async؛ شغّل codemod |
| 404 على صفحة موجودة | نسيان page.tsx في المقطع |
تحقّق أن المقطع له page.tsx (مجلد وحده لا ينشئ route) |
loading.tsx لا يظهر |
صفحة بلا await طويل، أو بيانات في cache |
تحقّق أن دالة fetch ليست في cache عدواني؛ أضف تأخيراً اصطناعياً للاختبار |
| «Each child in a list should have a unique key prop» على parallel route | نسيان default.tsx |
أنشئ default.tsx يُرجع null افتراضياً |
| Middleware يشتغل لكل الطلبات حتى الصور | لا matcher مُصدَّر | عرّف config.matcher دقيقاً |
| Layout يُعاد تركيبه عند كل تنقل | استخدام template.tsx بدل layout.tsx |
أعد تسمية إلى layout.tsx |