Перейти к содержимому

3. Файловая маршрутизация

Забудь о конфигах роутов. В Next.js маршрут определяется структурой папок в директории app/. Это одна из самых элегантных идей фреймворка: код организован так же, как URL. 📁

Файловая структура → URL
─────────────────────────────────────────────────
app/page.tsx → /
app/about/page.tsx → /about
app/blog/page.tsx → /blog
app/blog/[slug]/page.tsx → /blog/:slug
app/shop/[...path]/page.tsx → /shop/a/b/c/...

Самый простой случай — папка с именем сегмента:

app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── contact/
│ └── page.tsx → /contact
└── services/
├── page.tsx → /services
├── design/
│ └── page.tsx → /services/design
└── development/
└── page.tsx → /services/development
app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>О нас</h1>
<p>Мы команда разработчиков...</p>
</main>
);
}
// Можно добавить метаданные прямо в файле!
export const metadata = {
title: 'О нас | Мой сайт',
description: 'Узнайте о нашей команде',
};

Квадратные скобки в имени папки = динамический параметр:

app/blog/[slug]/page.tsx → /blog/hello-world
→ /blog/next-js-guide
→ /blog/anything-here
app/blog/[slug]/page.tsx
// params содержит { slug: 'hello-world' }
interface BlogPostProps {
params: Promise<{ slug: string }>; // Next.js 15: Promise!
}
export default async function BlogPost({ params }: BlogPostProps) {
const { slug } = await params; // Ожидаем Promise
const post = await getPostBySlug(slug);
if (!post) {
notFound(); // Показывает not-found.tsx
}
return (
<article>
<h1>{post.title}</h1>
<p>Слаг: {slug}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Генерация статических путей при сборке
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({
slug: post.slug,
}));
}

Несколько динамических сегментов:

app/[lang]/[country]/page.tsx → /en/us, /ru/ru, /de/de
app/[lang]/[country]/page.tsx
interface Props {
params: Promise<{ lang: string; country: string }>;
}
export default async function LocalePage({ params }: Props) {
const { lang, country } = await params;
return <p>Язык: {lang}, Страна: {country}</p>;
}

Три точки захватывают один или более сегментов пути:

app/docs/[...slug]/page.tsx → /docs/intro
→ /docs/api/users
→ /docs/api/users/create
→ НЕ /docs (нужен хотя бы один сегмент!)
app/docs/[...slug]/page.tsx
interface Props {
params: Promise<{ slug: string[] }>; // Массив сегментов!
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// /docs/api/users → slug = ['api', 'users']
// /docs/intro → slug = ['intro']
const path = slug.join('/'); // 'api/users'
const doc = await getDoc(path);
return (
<div>
<Breadcrumbs segments={slug} />
<article>{doc.content}</article>
</div>
);
}
// Генерация всех возможных путей
export async function generateStaticParams() {
const docs = await getAllDocs();
return docs.map(doc => ({
slug: doc.path.split('/'), // ['api', 'users']
}));
}

Двойные скобки захватывают ноль или более сегментов:

app/[[...slug]]/page.tsx → / (slug = undefined!)
→ /blog (slug = ['blog'])
→ /blog/post (slug = ['blog', 'post'])
app/shop/[[...filters]]/page.tsx
interface Props {
params: Promise<{ filters?: string[] }>; // Может быть undefined!
}
export default async function ShopPage({ params }: Props) {
const { filters } = await params;
// /shop → filters = undefined
// /shop/clothing → filters = ['clothing']
// /shop/clothing/summer → filters = ['clothing', 'summer']
const category = filters?.[0];
const subcategory = filters?.[1];
const products = await getProducts({ category, subcategory });
return (
<div>
<h1>
{!filters ? 'Все товары' : filters.join(' → ')}
</h1>
<ProductGrid products={products} />
</div>
);
}

Папки в круглых скобках не влияют на URL — только организуют код:

app/
├── (marketing)/ ← НЕ добавляет /marketing к URL
│ ├── layout.tsx ← Layout только для маркетинговых страниц
│ ├── page.tsx → /
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
└── (dashboard)/ ← НЕ добавляет /dashboard к URL
├── layout.tsx ← Layout только для дашборда
├── analytics/
│ └── page.tsx → /analytics
└── settings/
└── page.tsx → /settings
// app/(marketing)/layout.tsx
// Layout применяется только к marketing страницам
export default function MarketingLayout({ children }) {
return (
<>
<MarketingNav /> {/* Только для маркетинга */}
{children}
<MarketingFooter />
</>
);
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<DashboardSidebar />
{children}
</div>
);
}

Несколько root layouts:

// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
return (
<html lang="ru">
<body className="marketing-theme">{children}</body>
</html>
);
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
return (
<html lang="ru">
<body className="dashboard-theme">{children}</body>
</html>
);
}
// Каждый layout может иметь свой <html> и <body>!

Папки с символом @ создают именованные слоты для одновременного рендеринга нескольких страниц:

app/
└── dashboard/
├── layout.tsx ← Получает @analytics и @team
├── page.tsx → /dashboard
├── @analytics/
│ ├── page.tsx → Слот аналитики
│ └── revenue/
│ └── page.tsx
└── @team/
├── page.tsx → Слот команды
└── members/
└── page.tsx
app/dashboard/layout.tsx
interface DashboardLayoutProps {
children: React.ReactNode;
analytics: React.ReactNode; // Слот @analytics
team: React.ReactNode; // Слот @team
}
export default function DashboardLayout({
children,
analytics,
team,
}: DashboardLayoutProps) {
return (
<div className="dashboard-grid">
<div className="main">{children}</div>
<div className="analytics">{analytics}</div>
<div className="team">{team}</div>
</div>
);
}
// /dashboard → рендерит все три слота одновременно!

Перехватывают навигацию для показа контента в модальном окне (без потери контекста):

app/
├── photos/
│ ├── page.tsx → /photos (галерея)
│ └── [id]/
│ └── page.tsx → /photos/123 (полный экран)
└── @modal/
└── (.)photos/
└── [id]/
└── page.tsx → Перехватывает /photos/123 → показывает модалку!
// app/photos/[id]/page.tsx — полная страница
export default async function PhotoPage({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div>
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</div>
);
}
// app/@modal/(.)photos/[id]/page.tsx — модальное окно
export default async function PhotoModal({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
// При клике из /photos → показывает модалку
// При прямом переходе → показывает полную страницу

Синтаксис перехвата:

  • (.) — перехватить сегмент на том же уровне
  • (..) — перехватить сегмент на уровень выше
  • (..)(..) — на два уровня выше
  • (...) — перехватить из корня app

next/link — основной способ навигации в Next.js:

import Link from 'next/link';
// Базовое использование
<Link href="/about">О нас</Link>
// Динамический роут
<Link href={`/blog/${post.slug}`}>
{post.title}
</Link>
// С объектом href
<Link href={{ pathname: '/blog/[slug]', query: { slug: post.slug } }}>
{post.title}
</Link>
// Prefetch (по умолчанию true для видимых ссылок)
<Link href="/about" prefetch={false}>
О нас (без prefetch)
</Link>
// replace — не добавляет в history
<Link href="/login" replace>
Войти
</Link>
// scroll — скролл в начало страницы (по умолчанию true)
<Link href="/about" scroll={false}>
О нас
</Link>
// Программная навигация
import { useRouter } from 'next/navigation';
function MyComponent() {
const router = useRouter();
const handleClick = () => {
router.push('/about');
// router.replace('/about');
// router.back();
// router.forward();
// router.refresh(); // обновить текущую страницу
// router.prefetch('/about'); // предзагрузить
};
}

СинтаксисПримерURL
segment/app/about//about
[param]/app/blog/[slug]//blog/:slug
[...slug]/app/docs/[...slug]//docs/a/b/c
[[...slug]]/app/[[...all]]// или /a/b
(group)/app/(marketing)/без влияния на URL
@slot/app/@modal/параллельный слот
(.)segment/app/@modal/(.)photo/перехват

Файловая маршрутизация — это сердце Next.js App Router. Запомни:

  1. Папка = сегмент URL
  2. [param] = динамический параметр
  3. [...slug] = catch-all (1+)
  4. [[...slug]] = optional catch-all (0+)
  5. (group) = организация без URL
  6. @slot = параллельный рендеринг
  7. (.) = перехват навигации