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

22. SvelteKit: Маршрутизация

Привет! 👋 Роутинг в SvelteKit — это как карта города: у каждой улицы есть своё название (URL), у каждого здания — своя функция (компонент). И самое приятное — структура папок и есть маршруты. Никаких отдельных конфигурационных файлов!

Думай о роутинге SvelteKit как об оглавлении книги: src/routes/ — это оглавление, а каждая папка — это глава. Создал папку — создал маршрут. Магия!


В SvelteKit файлы маршрутов начинаются с +. Это не просто соглашение — это синтаксис, который позволяет SvelteKit отличать файлы маршрутов от вспомогательных файлов в той же папке.

src/routes/
├── +page.svelte ← Страница по пути /
├── +layout.svelte ← Layout для всех страниц
├── +error.svelte ← Страница ошибки
├── +page.ts ← Загрузка данных для /
├── about/
│ └── +page.svelte ← Страница /about
├── blog/
│ ├── +page.svelte ← Страница /blog
│ ├── +layout.svelte ← Layout только для /blog/**
│ └── [slug]/
│ └── +page.svelte ← Страница /blog/любой-слаг
└── (marketing)/ ← Группа роутов (нет в URL!)
├── +layout.svelte ← Layout только для этой группы
├── landing/
│ └── +page.svelte ← Страница /landing (не /(marketing)/landing!)
└── pricing/
└── +page.svelte ← Страница /pricing

+layout.svelte — это оболочка, в которую вставляются дочерние страницы. Идеально для навигации, футеров, провайдеров тем:

src/routes/+layout.svelte
<script lang="ts">
import { page } from '$app/stores';
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
// Слот children — это место, куда вставится дочерняя страница
let { children } = $props();
</script>
<div class="app">
<Navigation currentPath={$page.url.pathname} />
<main>
<!-- Здесь рендерится контент страницы -->
{@render children()}
</main>
<Footer />
</div>
<style>
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
</style>

Вложенные layouts:

<!-- src/routes/blog/+layout.svelte — только для /blog/** -->
<script lang="ts">
let { children, data } = $props();
</script>
<div class="blog-layout">
<aside class="sidebar">
<h3>Категории</h3>
{#each data.categories as cat}
<a href="/blog/category/{cat.slug}">{cat.name}</a>
{/each}
</aside>
<article class="content">
{@render children()}
</article>
</div>

Цепочка layouts:

Запрос /blog/my-post:
1. +layout.svelte (корневой — навигация + футер)
2. blog/+layout.svelte (блог — сайдбар)
3. blog/[slug]/+page.svelte (контент поста)

<!-- src/routes/+error.svelte — для любых ошибок в любом маршруте -->
<script lang="ts">
import { page } from '$app/stores';
</script>
<div class="error-page">
<h1>{$page.status}</h1>
<p>{$page.error?.message}</p>
{#if $page.status === 404}
<p>Страница не найдена 🕵️</p>
{:else if $page.status === 500}
<p>Что-то пошло не так на сервере 💥</p>
{/if}
<a href="/">На главную</a>
</div>
<!-- Можно создавать error.svelte для конкретных секций: -->
<!-- src/routes/blog/+error.svelte — только для ошибок в /blog/** -->
<script lang="ts">
import { page } from '$app/stores';
</script>
<div>
<h2>Ошибка в блоге</h2>
<p>Пост "{$page.params.slug}" не найден</p>
</div>

src/routes/
├── blog/
│ └── [slug]/+page.svelte ← /blog/hello-world
├── users/
│ └── [id]/
│ ├── +page.svelte ← /users/123
│ └── settings/
│ └── +page.svelte ← /users/123/settings
├── products/
│ └── [category]/
│ └── [id]/+page.svelte ← /products/electronics/42
src/routes/blog/[slug]/+page.svelte
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// data.slug доступен из load функции
// или через $page.params.slug
</script>
<h1>{data.post.title}</h1>
<div class="content">{@html data.post.html}</div>
src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params; // Тип: string
const post = await db.posts.findUnique({
where: { slug },
});
if (!post) {
error(404, { message: `Пост "${slug}" не найден` });
}
return { post };
};

Валидация параметров с матчерами:

// src/params/integer.ts — кастомный матчер
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return /^\d+$/.test(param); // Только целые числа
};
// Использование матчера в имени папки:
src/routes/users/[id=integer]/+page.svelte ← /users/123 ✅, /users/abc ❌

src/routes/docs/[...path]/+page.svelte
<!-- Совпадает с: /docs/intro, /docs/api/getting-started, /docs/a/b/c -->
<script lang="ts">
import { page } from '$app/stores';
// $page.params.path будет строкой: "api/getting-started"
let pathSegments = $page.params.path.split('/');
</script>
<nav>
{#each pathSegments as segment, i}
<span>
{#if i < pathSegments.length - 1}
<a href="/docs/{pathSegments.slice(0, i + 1).join('/')}">{segment}</a> /
{:else}
<strong>{segment}</strong>
{/if}
</span>
{/each}
</nav>

Необязательные параметры:

src/routes/[[lang]]/about/+page.svelte
← Совпадает и с /about, и с /ru/about, и с /en/about
// load функция для [[lang]]:
export const load: PageServerLoad = async ({ params }) => {
const lang = params.lang ?? 'ru'; // Необязательный параметр
const content = await getContent('about', lang);
return { content, lang };
};

Круглые скобки в имени папки создают группы роутов — они не добавляются в URL, но позволяют делиться layout-ами:

src/routes/
├── (app)/ ← Группа для авторизованных страниц
│ ├── +layout.svelte ← Проверяет авторизацию!
│ ├── dashboard/
│ │ └── +page.svelte ← URL: /dashboard (без /(app)/)
│ ├── profile/
│ │ └── +page.svelte ← URL: /profile
│ └── settings/
│ └── +page.svelte ← URL: /settings
├── (auth)/ ← Группа для страниц авторизации
│ ├── +layout.svelte ← Минимальный layout (без навигации)
│ ├── login/
│ │ └── +page.svelte ← URL: /login
│ └── register/
│ └── +page.svelte ← URL: /register
└── (marketing)/ ← Публичные маркетинговые страницы
├── +layout.svelte ← Layout с Hero секцией
├── +page.svelte ← URL: / (главная)
├── pricing/
│ └── +page.svelte ← URL: /pricing
└── about/
└── +page.svelte ← URL: /about
<!-- src/routes/(app)/+layout.svelte — защита авторизованных маршрутов -->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { LayoutData } from './$types';
let { children, data }: { children: any, data: LayoutData } = $props();
// Редирект если не авторизован (лучше делать в +layout.server.ts!)
</script>
{#if data.user}
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/profile">Профиль</a>
<a href="/settings">Настройки</a>
<span>Привет, {data.user.name}!</span>
</nav>
{@render children()}
{:else}
<p>Загрузка...</p>
{/if}

<script lang="ts">
import { page } from '$app/stores';
// $page содержит:
// $page.url — текущий URL (объект URL)
// $page.params — параметры маршрута { slug: 'hello' }
// $page.route — информация о маршруте { id: '/blog/[slug]' }
// $page.status — HTTP статус
// $page.error — объект ошибки
// $page.data — данные из load функций
// $page.form — данные форм (Form Actions)
$effect(() => {
console.log('Текущий URL:', $page.url.pathname);
console.log('Параметры:', $page.params);
});
</script>
<!-- Активная навигация с $page -->
<nav>
{#each [
{ href: '/', label: 'Главная' },
{ href: '/blog', label: 'Блог' },
{ href: '/about', label: 'О нас' },
] as link}
<a
href={link.href}
class:active={$page.url.pathname === link.href}
>
{link.label}
</a>
{/each}
</nav>

<script lang="ts">
import { goto, beforeNavigate, afterNavigate, preloadData, preloadCode } from '$app/navigation';
// Программная навигация:
async function handleLogin() {
await login();
goto('/dashboard', {
replaceState: true, // Не добавлять в историю
invalidateAll: true, // Инвалидировать все load функции
state: { fromLogin: true }, // Состояние истории (history.state)
});
}
// Перехватчики навигации:
beforeNavigate(({ to, from, type, cancel }) => {
if (hasUnsavedChanges) {
const confirmed = confirm('Есть несохранённые изменения. Уйти?');
if (!confirmed) {
cancel(); // Отменяем навигацию
}
}
console.log(`Уходим с ${from?.url.pathname} на ${to?.url.pathname}`);
console.log('Тип навигации:', type); // 'link' | 'goto' | 'popstate' | 'enter' | 'leave'
});
afterNavigate(({ to, from, type }) => {
// Аналитика:
analytics.track('page_view', { path: to?.url.pathname });
});
// Prefetch данных (как наводить мышь в Next.js):
function handleMouseEnter(href: string) {
preloadData(href); // Загружает данные для страницы заранее
}
function handleMouseEnterCode(href: string) {
preloadCode(href); // Загружает JavaScript код для страницы
}
</script>
<!-- Обычные ссылки SvelteKit — автоматически перехватывают клики -->
<a href="/about">О нас</a>
<!-- data-sveltekit-preload-data — prefetch при наведении -->
<a href="/blog" data-sveltekit-preload-data="hover">Блог</a>
<!-- data-sveltekit-preload-data="tap" — prefetch при клике (мобильные) -->
<a href="/shop" data-sveltekit-preload-data="tap">Магазин</a>
<!-- Отключить клиентскую навигацию: -->
<a href="/external" data-sveltekit-reload>Внешняя страница</a>

<script lang="ts">
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
let isLoading = $state(false);
let previousPage = $state('');
// Показываем индикатор загрузки:
beforeNavigate(() => {
isLoading = true;
});
afterNavigate(({ from }) => {
isLoading = false;
previousPage = from?.url.pathname ?? '';
// Скролл наверх при навигации:
window.scrollTo({ top: 0, behavior: 'smooth' });
});
</script>
{#if isLoading}
<div class="loading-bar"></div>
{/if}
{#if previousPage}
<button onclick={() => history.back()}>← Назад ({previousPage})</button>
{/if}

<!-- В +layout.svelte — глобальный prefetch -->
<svelte:head>
<!-- Prefetch критических страниц: -->
<link rel="prefetch" href="/api/user" />
</svelte:head>
<!-- app.html — настройка глобального prefetch -->
<!DOCTYPE html>
<html lang="ru">
<head>
%sveltekit.head%
</head>
<!-- data-sveltekit-preload-data на body — prefetch для всех ссылок: -->
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

Svelte 5 + SvelteKit поддерживает именованные snippets для более сложных layout-ов:

<!-- src/routes/+layout.svelte с именованными слотами (Svelte 5) -->
<script lang="ts">
let { children, sidebar } = $props();
</script>
<div class="two-column">
<aside>
{#if sidebar}
{@render sidebar()}
{:else}
<!-- Дефолтный сайдбар -->
<DefaultSidebar />
{/if}
</aside>
<main>
{@render children()}
</main>
</div>

// src/routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, url }) => {
// locals.user устанавливается в hooks.server.ts
if (!locals.user) {
redirect(307, `/login?redirectTo=${url.pathname}`);
}
return {
user: locals.user,
};
};
// src/hooks.server.ts — заполняем locals.user
import type { Handle } from '@sveltejs/kit';
import { verifyToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
const user = await verifyToken(token);
event.locals.user = user;
}
return resolve(event);
};