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

4. Layouts и Templates

Layout — это компонент-обёртка, который сохраняется между навигациями. Это суперсила App Router: ты пишешь навигацию, сайдбар, хедер один раз — и он не перерендеривается при переходах между страницами. Экономия ресурсов и плавный UX! ✨

Навигация /dashboard → /dashboard/settings
Pages Router: DashboardLayout РАЗМОНТИРУЕТСЯ и МОНТИРУЕТСЯ заново 😢
App Router: DashboardLayout ОСТАЁТСЯ, обновляется только children 🎉

Root layout — единственный обязательный файл в App Router. Он должен содержать теги <html> и <body>:

// app/layout.tsx — обязательный root layout
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
// Метаданные для всего сайта (наследуются дочерними)
export const metadata: Metadata = {
title: {
template: '%s | Мой сайт', // %s — заголовок страницы
default: 'Мой сайт', // если страница не указала title
},
description: 'Описание сайта',
metadataBase: new URL('https://mysite.com'),
openGraph: {
type: 'website',
locale: 'ru_RU',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider>
<Header />
{children}
<Footer />
</ThemeProvider>
</body>
</html>
);
}

Что обязательно в Root Layout:

  • Теги <html> и <body> (только в root!)
  • Может быть async (Server Component)
  • Должен принимать children

Каждый сегмент маршрута может иметь свой layout. Они вкладываются друг в друга:

app/
├── layout.tsx ← Root: Header + Footer для всего сайта
├── page.tsx → /
├── blog/
│ ├── layout.tsx ← Blog: ширина контента, теги
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
└── dashboard/
├── layout.tsx ← Dashboard: сайдбар
├── page.tsx → /dashboard
└── settings/
├── layout.tsx ← Settings: вкладки настроек
└── page.tsx → /dashboard/settings
app/dashboard/layout.tsx
import { DashboardSidebar } from '@/components/DashboardSidebar';
import { getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Server Component — можем делать запросы к БД!
const user = await getCurrentUser();
// Защита роутов прямо в layout
if (!user) {
redirect('/login');
}
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<DashboardSidebar user={user} />
<main style={{ flex: 1, padding: '24px' }}>
{children}
</main>
</div>
);
}
app/dashboard/settings/layout.tsx
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<h1>Настройки</h1>
<nav>
<Link href="/dashboard/settings">Профиль</Link>
<Link href="/dashboard/settings/security">Безопасность</Link>
<Link href="/dashboard/settings/billing">Оплата</Link>
</nav>
<div className="settings-content">{children}</div>
</div>
);
}

Итоговое дерево для /dashboard/settings:

<RootLayout> {/* app/layout.tsx */}
<DashboardLayout> {/* app/dashboard/layout.tsx */}
<SettingsLayout> {/* app/dashboard/settings/layout.tsx */}
<SettingsPage> {/* app/dashboard/settings/page.tsx */}
</SettingsPage>
</SettingsLayout>
</DashboardLayout>
</RootLayout>

Это тонкое, но важное различие:

// layout.tsx — ПЕРСИСТЕНТНЫЙ: сохраняется, НЕ перемонтируется
export default function Layout({ children }) {
const [count, setCount] = useState(0); // Сохраняется между переходами!
return (
<div>
<p>Счётчик: {count}</p> {/* Не сбросится при навигации */}
{children}
</div>
);
}
// template.tsx — ЭФЕМЕРНЫЙ: перемонтируется при каждой навигации
export default function Template({ children }) {
const [count, setCount] = useState(0); // Сбрасывается при каждом переходе!
return (
<div>
<p>Счётчик: {count}</p> {/* Сбросится при переходе на другую страницу */}
{children}
</div>
);
}

Когда использовать template.tsx:

app/template.tsx
// 1. Анимации входа/выхода страниц
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}
// Анимация будет воспроизводиться при КАЖДОМ переходе!
// 2. Трекинг просмотров страниц
// app/template.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export default function Template({ children }) {
const pathname = usePathname();
useEffect(() => {
// Выполнится при каждом переходе на новую страницу
analytics.track('page_view', { path: pathname });
}, [pathname]);
return <>{children}</>;
}

Next.js предоставляет два способа задать метаданные:

app/blog/layout.tsx
// 1. Статические метаданные
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Блог',
description: 'Статьи о программировании',
keywords: ['Next.js', 'React', 'TypeScript'],
openGraph: {
title: 'Блог | Мой сайт',
description: 'Статьи о программировании',
url: 'https://mysite.com/blog',
images: [
{
url: '/og-blog.jpg',
width: 1200,
height: 630,
alt: 'Блог',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Блог | Мой сайт',
description: 'Статьи о программировании',
},
};
export default function BlogLayout({ children }) {
return <div className="blog-layout">{children}</div>;
}
app/blog/[slug]/page.tsx
// 2. Динамические метаданные (для страниц с данными)
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
// generateMetadata — async функция для динамических метаданных
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return { title: 'Пост не найден' };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}

Наследование и слияние метаданных:

app/layout.tsx
export const metadata = {
title: { template: '%s | MySite', default: 'MySite' },
description: 'Лучший сайт в интернете',
};
// app/blog/layout.tsx
export const metadata = {
// title не указан → возьмёт из родителя template
description: 'Статьи о коде', // Переопределяет родительский
};
// app/blog/[slug]/page.tsx
export const metadata = {
title: 'Мой пост', // Итог: 'Мой пост | MySite'
// description не указан → 'Статьи о коде'
};

Layouts по умолчанию — Server Components. Это значит ты можешь делать запросы к БД прямо в них:

// app/dashboard/layout.tsx — Server Component (по умолчанию)
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export default async function DashboardLayout({ children }) {
// Прямой запрос к базе данных!
const session = await getSession();
const notifications = await db.notification.findMany({
where: { userId: session.user.id, read: false },
orderBy: { createdAt: 'desc' },
take: 5,
});
return (
<div>
<DashboardHeader
user={session.user}
notificationCount={notifications.length}
/>
<main>{children}</main>
</div>
);
}
// app/dashboard/layout.tsx — Client Component (если нужны hooks/events)
'use client';
import { useState, useEffect } from 'react';
export default function DashboardLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
// localStorage, window, DOM
useEffect(() => {
const saved = localStorage.getItem('sidebar');
if (saved) setSidebarOpen(saved === 'true');
}, []);
return (
<div>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? '◀' : '▶'}
</button>
{sidebarOpen && <Sidebar />}
<main>{children}</main>
</div>
);
}

// 1. Держи Root Layout минимальным и серверным
// app/layout.tsx — ХОРОШО
export default function RootLayout({ children }) {
return (
<html lang="ru">
<body>
{children} {/* Минимум логики */}
</body>
</html>
);
}
// 2. Используй вложенные layouts для изоляции
// Плохо: один жирный layout для всего
// Хорошо: отдельные layouts для разных секций
// 3. Server Components по умолчанию
// Добавляй 'use client' только когда нужны:
// - useState, useEffect, useRef
// - Event handlers
// - Browser APIs (window, localStorage)
// - Third-party клиентские библиотеки
// 4. Параметры из URL в layout
// app/dashboard/[userId]/layout.tsx
export default async function UserLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ userId: string }>;
}) {
const { userId } = await params;
const user = await getUser(userId);
return (
<UserProvider user={user}>
{children}
</UserProvider>
);
}

  1. Root Layout — обязательный, содержит <html> и <body>
  2. Вложенные Layouts — создаются в подпапках, вкладываются автоматически
  3. template.tsx — как layout, но перемонтируется при каждой навигации
  4. Метаданныеexport const metadata или generateMetadata() async
  5. Server by default — layouts серверные, добавляй 'use client' только если нужно