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 همیشه آرایه است:
// 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 باید در نوع، پارامتر را اختیاری (?) بنویسید، چون ممکن است وجود نداشته باشد:
// 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 مسیر اختصاصیتر را انتخاب میکند. ترتیب اولویت از بیشتر به کمتر:
- مسیر ثابت (
app/docs/intro) - قطعهی پویای معمولی (
app/docs/[slug]) - Catch-all (
app/docs/[...slug]) - 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
// 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 را از قبل بسازید، هر آبجکت باید برای آن کلید یک آرایه بدهد (نه یک رشته):
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»، یاد میگیریم از داخل کد (مثلاً بعد از یک کلیک یا ارسال فرم) به صفحهی دیگری برویم.
