Catch-all و Optional Catch-all Routes

مسیر پویای [slug] دقیقاً یک بخش از آدرس را می‌گیرد. اما گاهی تعداد بخش‌ها از قبل معلوم نیست: مثلاً مستنداتی با آدرس‌هایی مثل /docs/routing و /docs/routing/dynamic/catch-all، یا دسته‌بندی‌های تودرتو با عمق نامشخص. برای ساخت یک پوشه به ازای هر عمق ممکن، راه بهتری داریم: Catch-all Routes که هر تعداد بخش را یکجا می‌گیرند. در این درس دو نوع آن و تفاوتشان را می‌بینیم.

Catch-all Route: سه نقطه در کروشه

اگر نام پوشه را به شکل [...slug] بنویسید (سه نقطه قبل از نام)، آن پوشه همه‌ی بخش‌های باقی‌مانده‌ی آدرس را می‌گیرد. مقدار پارامتر به‌جای یک رشته، آرایه‌ای از رشته‌ها است:

مسیر فایل آدرس params
app/docs/[...slug]/page.tsx /docs/routing { slug: ["routing"] }
app/docs/[...slug]/page.tsx /docs/routing/dynamic { slug: ["routing", "dynamic"] }
app/docs/[...slug]/page.tsx /docs/a/b/c/d { slug: ["a", "b", "c", "d"] }
app/docs/[...slug]/page.tsx /docs مطابقت ندارد (۴۰۴)

نکته‌ی کلیدی: Catch-all معمولی حداقل یک بخش می‌خواهد. خود /docs بدون هیچ بخشی با آن تطبیق نمی‌کند.

Optional Catch-all Route: دو کروشه

اگر نام پوشه را با کروشه‌ی دوتایی بنویسید، مثل [[...slug]]، پارامتر اختیاری می‌شود: آدرس پایه‌ی بدون هیچ بخش هم با همین پوشه تطبیق می‌کند و در آن حالت slug برابر undefined است.

مسیر فایل آدرس params
app/docs/[[...slug]]/page.tsx /docs { } (یعنی slug وجود ندارد)
app/docs/[[...slug]]/page.tsx /docs/routing { slug: ["routing"] }
app/docs/[[...slug]]/page.tsx /docs/routing/dynamic { slug: ["routing", "dynamic"] }

مقایسه‌ی سه نوع قطعه‌ی پویا

نوع نام پوشه تعداد بخش‌ها نوع مقدار آدرس پایه
پویای معمولی [slug] دقیقاً یک string مطابقت ندارد
Catch-all [...slug] یک یا بیشتر string[] مطابقت ندارد
Optional Catch-all [[...slug]] صفر یا بیشتر string[] | undefined مطابقت دارد

نوشتن صفحه با params آرایه‌ای

مثل همیشه params یک Promise است. برای Catch-all معمولی، slug همیشه آرایه است:

tsx
// app/docs/[...slug]/page.tsx
type DocsPageProps = {
  params: Promise<{ slug: string[] }>;
};

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params;

  return <h1>مسیر: {slug.join(" / ")}</h1>;
}

برای Optional Catch-all باید در نوع، پارامتر را اختیاری (?) بنویسید، چون ممکن است وجود نداشته باشد:

tsx
// app/docs/[[...slug]]/page.tsx
type DocsPageProps = {
  params: Promise<{ slug?: string[] }>;
};

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug = [] } = await params;

  if (slug.length === 0) {
    return <h1>فهرست مستندات</h1>;
  }

  return <h1>مسیر: {slug.join(" / ")}</h1>;
}
  • slug = [] در Destructuring یک مقدار پیش‌فرض می‌دهد تا بتوانیم بدون ترس از undefined از length استفاده کنیم.
  • slug.join(" / ") بخش‌ها را برای نمایش به هم می‌چسباند.
  • هر عنصر آرایه یک بخش از آدرس است و مثل [slug] همیشه رشته است.

اولویت تطبیق مسیرها

وقتی چند پوشه می‌توانند یک آدرس را بگیرند، Next.js مسیر اختصاصی‌تر را انتخاب می‌کند. ترتیب اولویت از بیشتر به کمتر:

  1. مسیر ثابت (app/docs/intro)
  2. قطعه‌ی پویای معمولی (app/docs/[slug])
  3. Catch-all (app/docs/[...slug])
  4. Optional Catch-all (app/docs/[[...slug]])

مثلاً اگر هم app/docs/intro/page.tsx و هم app/docs/[...slug]/page.tsx وجود داشته باشد، آدرس /docs/intro به صفحه‌ی ثابت می‌رود و بقیه‌ی آدرس‌ها به Catch-all.

قاعده‌ی مهم دیگر: در یک سطح نمی‌توان هم Optional Catch-all و هم page.tsx ثابت داشت، چون هر دو آدرس پایه را تعریف می‌کنند و Next.js خطای تداخل می‌دهد. اگر فقط Catch-all معمولی دارید، می‌توانید کنارش یک page.tsx برای آدرس پایه بگذارید.

عملی: یک آزمایشگاه کوچک

برای دیدن رفتار این مسیرها، یک صفحه‌ی آزمایشی با Optional Catch-all می‌سازیم. این فقط برای تمرین است و در پایان آن را حذف می‌کنیم تا ساختار DevNotes ثابت بماند.

فایل جدید: app/(public)/playground/[[...slug]]/page.tsx

tsx
// app/(public)/playground/[[...slug]]/page.tsx
type PlaygroundPageProps = {
  params: Promise<{ slug?: string[] }>;
};

export default async function PlaygroundPage({
  params,
}: PlaygroundPageProps) {
  const { slug = [] } = await params;

  const segments = slug.map((part) => decodeURIComponent(part));

  return (
    <main>
      <h1>آزمایشگاه مسیرها</h1>
      {segments.length === 0 ? (
        <p>شما در آدرس پایه هستید و هیچ بخشی وجود ندارد.</p>
      ) : (
        <>
          <p>تعداد بخش‌ها: {segments.length}</p>
          <ol>
            {segments.map((part, index) => (
              <li key={`${index}-${part}`}>{part}</li>
            ))}
          </ol>
        </>
      )}
    </main>
  );
}

توضیح کد

  • نوع slug?: string[] نشان می‌دهد پارامتر ممکن است نباشد؛ همان خاصیت Optional Catch-all.
  • decodeURIComponent: کاراکترهای غیرلاتین (مثل فارسی) در بخش‌های مسیر به شکل کدگذاری‌شده می‌رسند و با این تابع به متن عادی برمی‌گردند. با این کار می‌توانید آدرسی مثل /playground/وب/ریاکت را هم امتحان کنید.
  • کلید key را از ترکیب اندیس و متن ساخته‌ایم؛ چون بخش‌ها ممکن است تکراری باشند و فقط متن برای یکتا بودن کافی نیست.
  • <>...</> یک Fragment است تا عناصر را بدون تگ اضافی گروه کنیم.

خروجی و رفتار مورد انتظار

آدرس نتیجه
/playground پیام «شما در آدرس پایه هستید...»
/playground/web تعداد بخش‌ها ۱؛ فهرست: web
/playground/web/frontend/react تعداد بخش‌ها ۳؛ فهرست: web، frontend، react

حالا امتحان کنید فایل را به پوشه‌ی [...slug] (کروشه‌ی تکی) تغییر نام بدهید: آدرس /playground دیگر کار نمی‌کند و ۴۰۴ می‌دهد؛ این همان تفاوت Catch-all معمولی و اختیاری است. بعد از تمرین، پوشه‌ی app/(public)/playground را کاملاً حذف کنید.

کاربردهای واقعی

  • مستندات و صفحات محتوایی: آدرس‌هایی با عمق متغیر مثل /docs/guide/routing/dynamic.
  • دسته‌بندی‌های تودرتو: مثل /topics/web/frontend/react که هر بخش یک سطح از دسته است.
  • مسیر شبیه سیستم فایل: مثل مدیریت پوشه‌ها و فایل‌ها.
  • صفحات ساخته‌شده از سیستم مدیریت محتوا (CMS): که مسیرشان را مدیر سایت تعیین می‌کند.

DevNotes فعلاً به این نوع مسیر نیاز ندارد؛ نوشته‌های ما فقط یک سطح دارند (/posts/[slug]). اگر بعداً دسته‌بندی تودرتو بخواهید، همین ابزار آماده است.

generateStaticParams برای Catch-all

اگر بخواهید مسیرهای Catch-all را از قبل بسازید، هر آبجکت باید برای آن کلید یک آرایه بدهد (نه یک رشته):

tsx
export async function generateStaticParams() {
  return [
    { slug: ["routing"] },
    { slug: ["routing", "dynamic"] },
  ];
}

این یعنی صفحه‌های /docs/routing و /docs/routing/dynamic هنگام Build ساخته می‌شوند. آموزش کامل generateStaticParams را در درس «مسیرهای پویا، params ناهمگام و مسیرهای تودرتو» داشتیم.

نکته‌های مهم

  • Catch-all برای ۴۰۴ کل سایت مناسب نیست. برای صفحه‌ی «پیدا نشد» از not-found.tsx استفاده کنید (درس «Redirect، not-found.tsx و notFound()»). Catch-all ساختن فقط برای دریافت همه‌چیز، مسیرهای دیگر را بی‌دلیل پنهان می‌کند.
  • بخش‌های آدرس را اعتبارسنجی کنید. کاربر می‌تواند هر عمق و هر متنی بنویسد. طول آرایه و محتوای هر بخش را بررسی کنید و اگر مسیر معتبر نبود، صفحه‌ی ۴۰۴ نشان دهید. هرگز بخش‌های آدرس را مستقیم برای ساخت مسیر فایل یا پرس‌وجوی پایگاه داده استفاده نکنید.
  • نوع را با نوع پوشه هماهنگ کنید: برای [[...slug]] حتماً slug?: string[] بنویسید؛ در غیر این صورت TypeScript حالت undefined را نمی‌بیند و خطای زمان اجرا ممکن است پیش بیاید.
  • فقط یک Catch-all در هر سطح: داشتن دو پوشه‌ی Catch-all هم‌سطح معنا ندارد و خطا می‌دهد.
  • Catch-all باید آخرین قطعه‌ی مسیر باشد: زیر پوشه‌ی [...slug] نمی‌توان پوشه‌ی دیگری (مثل edit) گذاشت و انتظار داشت بعد از آن تطبیق شود.

جمع‌بندی

  • [...slug] یک یا چند بخش از آدرس را می‌گیرد و slug آرایه‌ای از رشته‌ها است؛ آدرس پایه با آن تطبیق ندارد.
  • [[...slug]] (Optional Catch-all) صفر یا چند بخش را می‌گیرد؛ برای آدرس پایه slug وجود ندارد (undefined).
  • مسیر ثابت از قطعه‌ی پویا و آن از Catch-all اولویت دارد؛ Optional Catch-all و page.tsx هم‌سطح با هم تداخل دارند.
  • برای نمایش بخش‌های فارسی از decodeURIComponent استفاده می‌کنیم و ورودی آدرس را همیشه اعتبارسنجی می‌کنیم.
  • برای صفحه‌ی پیدا نشد از not-found.tsx استفاده می‌شود، نه Catch-all.
  • در DevNotes فعلاً به این نوع مسیر نیاز نداریم و فقط آن را در یک تمرین آزمودیم.
  • در درس بعد، «مسیریابی برنامه‌ای با useRouter»، یاد می‌گیریم از داخل کد (مثلاً بعد از یک کلیک یا ارسال فرم) به صفحه‌ی دیگری برویم.