SvelteKit routing
Aug 22, 2026 SvelteKit
SvelteKit maps folders to URLs. src/routes/about/+page.svelte is /about. There is no routes.ts.
SvelteKit only treats + files as routes. A helper in the same folder does not get a URL.
The SvelteKit tutorial already builds a list, a [id] page, and a form. This post covers the other + files in a hardware shop. The routing docs list every file. Advanced routing covers rest params and groups.
We will end up with these files:
src/lib/server/
├── help.ts
└── products.ts
src/routes/
├── +error.svelte
├── +layout.svelte
├── +page.svelte
├── about/
│ └── +page.svelte
├── api/
│ └── products/
│ └── +server.ts
├── help/
│ └── [...slug]/
│ ├── +page.server.ts
│ └── +page.svelte
└── products/
├── +page.server.ts
├── +page.svelte
└── [slug]/
├── +page.server.ts
└── +page.svelte Table of Contents
Step 1: create the project
If you already have an app from the tutorial, reuse it. Otherwise install Node 18 or newer and run the Svelte CLI:
npx sv create shop
cd shop
npm install
npm run dev Pick TypeScript. Skip the add-ons.
Open http://localhost:5173. Vite is the dev server.
This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The SvelteKit 3 migration guide lists those renames. The folder names below stay the same.
Step 2: static pages and a layout
Replace src/routes/+layout.svelte. A layout wraps every child page. {@render children()} is the page.
<script lang="ts">
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/help">Help</a>
<a href="/about">About</a>
</nav>
{@render children()} SvelteKit uses ordinary <a> tags. There is no <Link> component. A click on an in-app href imports the next page and runs its load functions. The layout stays mounted.
Replace src/routes/+page.svelte:
<h1>Shop</h1>
<p>
Hammers, tape, sandpaper.
<a href="/products">See the products</a>.
</p> Create src/routes/about/+page.svelte:
<h1>About</h1>
<p>We sell hardware. Shipping notes are under Help.</p> src/routes/+page.svelte is /. src/routes/about/+page.svelte is /about. Dropping about.svelte into src/routes does nothing. The page file has to be about/+page.svelte.
The default project template puts data-sveltekit-preload-data="hover" on <body> in src/app.html. Hovering a link starts the load call before the click. The link options page lists the other data-sveltekit-* attributes.
Step 3: a list and a [slug]
[slug] is a parameter. /products/claw-hammer puts claw-hammer in params.slug.
Create src/lib/server/products.ts. Keep it under $lib/server so a .svelte file cannot import it. That is the full-stack split. Server data stays off the client bundle.
export type Product = {
slug: string;
name: string;
price: string;
description: string;
};
export const products: Product[] = [
{
slug: 'claw-hammer',
name: 'Claw hammer',
price: '$18',
description: '16 oz steel head. The claw pulls nails.'
},
{
slug: 'masking-tape',
name: 'Masking tape',
price: '$4',
description: '24 mm roll. Holds paper while paint dries.'
},
{
slug: '80-grit-sandpaper',
name: '80 grit sandpaper',
price: '$6',
description: 'Five sheets. For stripping old finish.'
}
];
export function getProduct(slug: string): Product | null {
return products.find((product) => product.slug === slug) ?? null;
} Create src/routes/products/+page.server.ts. load in a .server.ts file runs only on the server.
import { products } from '$lib/server/products';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { products };
}; Create src/routes/products/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Products</h1>
<ul>
{#each data.products as product (product.slug)}
<li>
<a href="/products/{product.slug}">{product.name}</a>
</li>
{/each}
</ul> $props() is how Svelte 5 reads props. data is whatever load returned. The (product.slug) in {#each} is the key. Use a stable id, not the index.
Create src/routes/products/[slug]/+page.server.ts:
import { error } from '@sveltejs/kit';
import { getProduct } from '$lib/server/products';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const product = getProduct(params.slug);
if (!product) {
error(404, 'Product not found');
}
return { product };
}; error() throws. SvelteKit then renders the closest +error.svelte. /products/nope should 404.
Create src/routes/products/[slug]/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>{data.product.name}</h1>
<p>{data.product.description}</p>
<p>{data.product.price}</p>
<p><a href="/products">All products</a></p> Open /products, then a title. The nav stays. Only the page body swaps.
+page.ts also exports load, but that function runs in the browser on client navigations. It cannot import $lib/server. Use +page.server.ts when the data is private or lives on the filesystem.
Step 4: nested help pages
A rest parameter eats the rest of the path. src/routes/help/[...slug]/+page.svelte matches /help, /help/returns, and /help/returns/damaged. params.slug is the leftover, slashes included. On /help itself it is missing, so treat it as ''.
Create src/lib/server/help.ts:
export type Article = {
title: string;
body: string;
};
export const articles: Record<string, Article> = {
'': {
title: 'Help',
body: 'Shipping is free over $50. Returns are 30 days.'
},
returns: {
title: 'Returns',
body: 'Unused items go back in the original box. Start from the order email.'
},
'returns/damaged': {
title: 'Damaged goods',
body: 'Photograph the box and the item. We replace it or refund it.'
}
};
export function getArticle(path: string): Article | null {
return articles[path] ?? null;
} Create src/routes/help/[...slug]/+page.server.ts:
import { error } from '@sveltejs/kit';
import { getArticle } from '$lib/server/help';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const path = params.slug ?? '';
const article = getArticle(path);
if (!article) {
error(404, 'Page not found');
}
return {
article,
links: [
{ href: '/help', label: 'Help' },
{ href: '/help/returns', label: 'Returns' },
{ href: '/help/returns/damaged', label: 'Damaged goods' }
]
};
}; Unknown paths still 404. A catch-all that always returns a page would hide typos.
Create src/routes/help/[...slug]/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>{data.article.title}</h1>
<p>{data.article.body}</p>
<ul>
{#each data.links as link (link.href)}
<li><a href={link.href}>{link.label}</a></li>
{/each}
</ul> Click from Help to Returns to Damaged goods. Same page component, different params.slug.
Rest parameters are greedy. [...slug] after a required param still works, as in docs/[lang]/[...path]. An optional param cannot follow a rest param. [...rest]/[[optional]] would leave the optional unused. See optional parameters.
Step 5: an error page
Create src/routes/+error.svelte:
<script lang="ts">
import { page } from '$app/state';
</script>
<h1>{page.status}</h1>
<p>{page.error?.message}</p>
<p><a href="/">Home</a></p> $app/state is the current page. page.status is 404 after error(404, ...). $app/stores is the older API. Your own shared UI state is $state and context, not writable.
SvelteKit walks up looking for the closest +error.svelte. A file next to products/[slug] would only cover that branch.
If load in a layout throws, SvelteKit skips the error page next to that layout and uses one above it.
/products/nope still matches [slug]. load throws, so a products/[slug]/+error.svelte would run if you added one. /does-not-exist matches no page at all, so the root +error.svelte runs.
+error.svelte does not run for errors inside hooks.server.ts or inside +server.ts. Those return JSON or src/error.html.
Step 6: a JSON route
A +server.ts file is an HTTP handler, not a page. Export GET, POST, and the other verbs. Each receives a RequestEvent and returns a Response.
Create src/routes/api/products/+server.ts:
import { json } from '@sveltejs/kit';
import { products } from '$lib/server/products';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(products);
}; Visit /api/products. You should see the three items as JSON. json() sets the content type.
Layouts do not wrap +server.ts. A load in +layout.server.ts does not run for this request. Auth for a download has to live in the handler, the same way serving files behind auth checks the session on GET. Shared work goes in handle in hooks.server.ts.
You can put +server.ts next to +page.svelte. A browser navigation still renders the page. A fetch that asks for JSON hits the handler. For a form, use form actions instead of a hand-rolled POST. The tutorial and the file upload post both use actions.
Other route files
A folder in parentheses does not appear in the URL. (shop)/products/+page.svelte is still /products. That is how you give a marketing tree and an app tree different layouts without stuffing /app into every path. See advanced layouts.
src/routes/[[lang]]/about/+page.svelte matches /about and /en/about. params.lang is undefined on the first URL.
[slug] accepts anything. /products/rocket still hits the page, then load 404s. A matcher in src/params can reject the URL before load runs. Put the page at src/routes/products/[slug=item]/+page.svelte. Export match from src/params/item.ts and return true only for known slugs. If match returns false, SvelteKit tries the next route and 404s if none match. Matchers run on the server and in the browser.
Several routes can match one path. More specific wins. /products/claw-hammer prefers a literal folder over [slug], and [slug] over [...slug]. Read the sort order if two folders could match.
+page@.svelte skips up to the root layout. +page@products.svelte skips up to the products layout. Use this when one page should not inherit a nested layout.
If the destination only exists inside a click handler, goto from $app/navigation navigates. Use an <a> when you already know the href. The URL then works with JavaScript off, and the browser can show it in the status bar on hover.
Things that trip people up
about.svelte in src/routes is not /about. The page file has to be about/+page.svelte.
Do not import $lib/server/products from a .svelte file or from +page.ts. Keep those imports in +page.server.ts and +server.ts.
A rest param that always succeeds turns every typo into a 200. Call error(404) for unknown paths.
(private) in a folder name does not hide the route. The URL still works. SPA protected routes are a client check. Real protection is a session check in load, in the action, or in handle.
If you prerender /products/[slug], load runs at build time. You have to tell the prerenderer which slugs exist, through entries or by crawling links. A live catalog that changes after deploy needs a server at request time.
What to add next
The SvelteKit tutorial adds a form action on top of this same folder map.
Put a session in front of /products with HttpOnly cookies. A file upload is another +page.server.ts, with a File instead of a string.