تخطى إلى المحتوى
·11 دقيقة قراءة

هيكلة مشاريع Next.js على نطاق واسع: ما تعلمته بالطريقة الصعبة

دروس مكتسبة بالتجربة حول تنظيم قواعد أكواد Next.js التي لا تنهار تحت ثقلها. معمارية قائمة على الميزات، مجموعات المسارات، حدود الخادم/العميل، فخاخ ملفات barrel، وهيكل مجلدات حقيقي يمكنك استخدامه.

مشاركة:X / TwitterLinkedIn

كل مشروع Next.js يبدأ نظيفا. بضع صفحات، مجلد للمكونات، ربما ملف lib/utils.ts. تشعر بالإنتاجية. تشعر بالتنظيم.

ثم تمر ستة أشهر. ينضم ثلاثة مطورين. مجلد المكونات يحتوي على 140 ملفا. أنشأ أحدهم utils2.ts لأن utils.ts كان 800 سطر. لا أحد يعرف أي تخطيط يغلف أي مسار. عملية البناء تستغرق أربع دقائق ولست متأكدا لماذا.

مررت بهذه الدورة عدة مرات. ما يلي هو ما أفعله فعلا الآن، بعد أن تعلمت معظم هذه الدروس من خلال إطلاق الشيء الخاطئ أولا.

الهيكل الافتراضي ينهار بسرعة#

توثيق Next.js يقترح هيكلا للمشروع. إنه جيد. يعمل لمدونة شخصية أو أداة صغيرة. لكن في اللحظة التي يعمل فيها أكثر من مطورين اثنين على نفس قاعدة الكود، يبدأ النهج "القائم على النوع" بالتشقق:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     ← the beginning of the end
  app/
    page.tsx
    dashboard/
      page.tsx

المشكلة ليست أن هذا "خاطئ". المشكلة أنه يتوسع خطيا مع الميزات لكن ليس مع الاستيعاب. عندما يحتاج مطور جديد للعمل على الفواتير، عليه فتح components/ و hooks/ و utils/ و types/ و app/ في آن واحد. ميزة الفواتير موزعة على خمسة مجلدات. لا يوجد مكان واحد يقول "هذا ما يبدو عليه نظام الفواتير."

شاهدت فريقا من أربعة مطورين يقضون سبرنت كاملا في إعادة الهيكلة إلى هذا الشكل بالذات، مقتنعين أنه الطريقة "النظيفة". في غضون ثلاثة أشهر عادوا إلى حالة الارتباك، لكن بمجلدات أكثر فقط.

التجميع القائم على الميزات يفوز. في كل مرة.#

النمط الذي نجا فعلا من الاحتكاك بفريق حقيقي هو التجميع القائم على الميزات. الفكرة بسيطة: كل ما يتعلق بميزة معينة يعيش معا.

ليس "كل المكونات في مجلد واحد وكل الخطافات في آخر." العكس تماما. جميع المكونات والخطافات والأنواع والأدوات المساعدة للفواتير تعيش داخل features/invoicing/. كل ما يتعلق بالمصادقة يعيش داخل features/auth/.

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

لاحظ الاستيرادات. كلها نسبية، كلها داخل الميزة. هذا الخطاف لا يصل إلى مجلد hooks/ عام أو utils/format.ts مشترك. يستخدم منطق التنسيق الخاص بميزة الفواتير لأن تنسيق أرقام الفواتير هو شأن يخص الفواتير، وليس شأنا عاما.

الجزء غير البديهي: هذا يعني أنه سيكون لديك بعض التكرار. قد يكون لميزة الفواتير ملف format.ts وكذلك لميزة الفوترة ملف format.ts آخر. هذا لا بأس به. هذا في الواقع أفضل من ملف formatters.ts مشترك تعتمد عليه 14 ميزة ولا يستطيع أحد تعديله بأمان.

قاومت هذا لفترة طويلة. كان مبدأ DRY بمثابة عقيدة. لكنني رأيت ملفات الأدوات المشتركة تصبح أخطر الملفات في قاعدة الكود، ملفات حيث "إصلاح بسيط" لميزة واحدة يكسر ثلاث ميزات أخرى. التجميع مع تكرار مُتحكَّم فيه أسهل في الصيانة من المشاركة القسرية.

هيكل المجلدات الذي أستخدمه فعلا#

إليك الشجرة الكاملة. تم اختبارها في معارك حقيقية عبر تطبيقين إنتاجيين بفرق من 4-8 أشخاص:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

بعض الأشياء التي يجب ملاحظتها:

  • app/ يحتوي فقط على منطق التوجيه. ملفات الصفحات خفيفة. تستورد من features/ وتركب. يجب أن يكون ملف الصفحة 20-40 سطرا كحد أقصى.
  • features/ هو المكان الذي يعيش فيه الكود الحقيقي. كل ميزة مستقلة بذاتها. يمكنك حذف features/invoicing/ ولن ينكسر شيء آخر (باستثناء الصفحات التي تستورد منها، وهذا بالضبط الترابط الذي تريده).
  • shared/ ليس مكبا. لديه تصنيفات فرعية صارمة. المزيد عن هذا أدناه.

مجموعات المسارات: أكثر من مجرد تنظيم#

مجموعات المسارات، المجلدات (بين أقواس)، هي واحدة من أقل الميزات استخداما في App Router. لا تؤثر على عنوان URL إطلاقا. (dashboard)/invoices/page.tsx يُعرض على /invoices، وليس /dashboard/invoices.

لكن القوة الحقيقية هي عزل التخطيطات. كل مجموعة مسارات تحصل على layout.tsx خاص بها:

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

فحص المصادقة يحدث مرة واحدة على مستوى التخطيط، وليس في كل صفحة. الصفحات العامة تحصل على غلاف مختلف تماما. صفحات المصادقة لا تحصل على أي تخطيط، فقط بطاقة في المنتصف. هذا نظيف وصريح ويصعب الخطأ فيه.

رأيت فرقا تضع فحوصات المصادقة داخل الـ middleware وداخل الصفحات الفردية وداخل المكونات، كلها في نفس الوقت. اختر مكانا واحدا. لمعظم التطبيقات، تخطيط مجموعة المسارات هو ذلك المكان.

مجلد shared/ ليس utils/#

أسرع طريقة لإنشاء كابوس صيانة هي ملف lib/utils.ts. يبدأ صغيرا. ثم يصبح درج القمامة في قاعدة الكود، حيث ينتهي كل دالة ليس لها مكان واضح.

هذه قاعدتي: مجلد shared/ يتطلب تبريرا. شيء ما يدخل shared/ فقط إذا:

  1. يُستخدم من قبل 3+ ميزات، و
  2. عام فعلا (وليس منطق أعمال متنكرا بقناع عام)

دالة تنسيق تاريخ تنسق تواريخ استحقاق الفواتير؟ تذهب في features/invoicing/utils/. دالة تنسيق تاريخ تأخذ Date وتعيد نصا محليا؟ يمكن أن تذهب في shared/.

المجلد الفرعي shared/lib/ مخصص تحديدا لتكامل الأطراف الثالثة والبنية التحتية: عملاء قواعد البيانات ومكتبات المصادقة ومزودي الدفع. هذه أشياء يعتمد عليها التطبيق بأكمله لكن لا تملكها ميزة واحدة.

typescript
// shared/lib/db.ts — هذا ينتمي إلى shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — هذا لا ينتمي إلى shared
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

إذا كنت تتجه نحو shared/utils/ ولا تستطيع تسمية الملف بشيء أكثر تحديدا من helpers.ts، توقف. ذلك الكود على الأرجح ينتمي إلى ميزة معينة.

مكونات الخادم مقابل العميل: ارسم الخط بتعمد#

إليك نمطا أراه باستمرار في قواعد الكود التي تعاني من مشاكل الأداء:

typescript
// هذا يبدو معقولا لكنه خطأ
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

الصفحة مُعلّمة بـ "use client". الآن كل ما تستورده، InvoiceTable و InvoiceFilters والخطاف وكل تبعية لكل تبعية، موجود في حزمة العميل. جدولك المكون من 200 صف الذي كان يمكن عرضه على الخادم بدون أي JavaScript يُرسل الآن إلى المتصفح.

الحل هو دفع "use client" إلى المكونات الطرفية التي تحتاجه فعلا:

typescript
// app/(dashboard)/invoices/page.tsx — مكون خادم (بدون توجيه)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* مكون عميل — يحتوي على فلاتر تفاعلية */}
      <InvoiceTable data={invoices} /> {/* مكون خادم — يعرض الصفوف فقط */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

القاعدة التي أتبعها: كل مكون هو مكون خادم حتى يحتاج useState أو useEffect أو معالجات أحداث أو واجهات برمجة المتصفح. عندها فقط أضف "use client"، وحاول أن تجعل ذلك المكون صغيرا قدر الإمكان.

نمط children هو أفضل صديق لك هنا. مكون العميل يمكنه استقبال مكونات خادم كـ children:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children يمكن أن تكون مكونات خادم */}
    </div>
  );
}

هكذا تحافظ على 90% من شجرة مكوناتك على الخادم مع الحفاظ على التفاعلية حيث يهم الأمر.

ملفات Barrel: القاتل الصامت للبناء#

اعتدت وضع index.ts في كل مجلد. استيرادات نظيفة، أليس كذلك؟

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

ثم يمكنني الاستيراد هكذا:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

يبدو أنيقا. إليك المشكلة: عندما يرى المُحزِّم هذا الاستيراد، عليه تقييم ملف barrel بالكامل لمعرفة ما هو InvoiceTable. هذا يعني أنه يحمل InvoiceRow و InvoiceForm و InvoicePdf أيضا، حتى لو كنت تحتاج الجدول فقط.

في أحد المشاريع، أدى حذف ملفات barrel الداخلية إلى انخفاض عدد الوحدات من 11,000 إلى 3,500 أثناء التطوير. خادم التطوير انتقل من تحميل صفحات يستغرق 5-10 ثوان إلى أقل من ثانيتين. JavaScript للتحميل الأول انخفض من أكثر من ميغابايت إلى حوالي 200 كيلوبايت.

بنت Vercel خاصية optimizePackageImports في next.config.ts للتعامل مع هذا للحزم الخارجية من node_modules. لكنها لا تصلح ملفات barrel الخاصة بك. لكودك الخاص، الجواب أبسط: لا تستخدمها.

typescript
// افعل هذا
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// ليس هذا
import { InvoiceTable } from "@/features/invoicing/components";

نعم، مسار الاستيراد أطول. عملية البناء أسرع، الحزم أصغر، وخادم التطوير لا يختنق. هذه مقايضة سأقبلها في كل مرة.

الاستثناء الوحيد: واجهة API العامة للميزة. إذا كان features/invoicing/ يكشف عن ملف index.ts واحد على مستوى جذر الميزة يحتوي فقط على 2-3 أشياء يُسمح للميزات الأخرى باستيرادها، فهذا جيد. يعمل كحدود، وليس اختصارا للراحة. لكن ملفات barrel داخل المجلدات الفرعية مثل components/index.ts أو hooks/index.ts؟ احذفها.

صفحات خفيفة، ميزات ثقيلة#

أهم قاعدة معمارية أتبعها هي هذه: ملفات الصفحات هي توصيلات، وليست منطقا.

ملف صفحة في app/ يجب أن يفعل ثلاثة أشياء:

  1. جلب البيانات (إذا كان مكون خادم)
  2. استيراد مكونات الميزات
  3. تركيبها معا

هذا كل شيء. لا منطق أعمال. لا عرض شرطي معقد. لا أشجار JSX من 200 سطر. إذا كان ملف صفحة ينمو فوق 40 سطرا، فأنت تضع كود الميزة في المكان الخاطئ.

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

اثنا عشر سطرا. الصفحة تجلب البيانات التي تحتاجها، تتعامل مع حالة عدم الوجود، وتفوض كل شيء آخر للميزة. إذا تغيرت واجهة تفاصيل الفاتورة، تعدل features/invoicing/، وليس app/. إذا تغير المسار، تنقل ملف الصفحة، وكود الميزة لا يهمه.

هكذا تحصل على قاعدة كود حيث يمكن لـ 15 مطورا العمل في وقت واحد دون أن يتداخلوا.

الحقيقة غير المريحة#

لا يوجد هيكل مجلدات يمنع الكود السيئ. رأيت مشاريع منظمة بشكل جميل مليئة بتجريدات فظيعة، وهياكل مسطحة فوضوية كان العمل فيها سهلا لأن الكود نفسه كان واضحا.

الهيكل أداة، وليس هدفا. النهج القائم على الميزات الذي وصفته هنا يعمل لأنه يحسّن الشيء الأهم على نطاق واسع: هل يستطيع مطور لم يرَ قاعدة الكود هذه من قبل أن يجد ما يحتاجه ويغيره دون كسر شيء آخر؟

إذا كان هيكلك الحالي يجيب بـ "نعم" على هذا السؤال، لا تُعِد هيكلته لأن تدوينة أخبرتك بذلك، حتى هذه التدوينة.

لكن إذا كان مطوروك يقضون وقتا في التنقل في قاعدة الكود أكثر من كتابة الكود، إذا كان "أين يذهب هذا؟" سؤالا متكررا في مراجعات الكود، إذا كان تغيير في الفوترة يكسر لوحة التحكم بطريقة ما، فإن هيكلك يحاربك. والأنماط أعلاه هي كيف أصلحت ذلك، في كل مرة.

مقالات ذات صلة