SvelteKit layouts
Aug 30, 2026 SvelteKit
A layout holds the parts of the interface shared by several pages, such as the nav or a sidebar. +page.svelte is destroyed when you leave its URL, but the layout above it stays mounted. Put a value in $state there and it survives the click.
Layouts can nest. src/routes/+layout.svelte wraps the whole app, while src/routes/account/+layout.svelte wraps /account and its children inside the root layout.
The tutorial and routing posts put a nav in the root layout. Here we will add an account layout, load shared data for it, and move the public pages into a (shop) group so /login can skip the shop nav.
The examples use Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The SvelteKit 3 migration guide lists the renamed APIs.
The finished example has these files:
src/routes/
├── +layout.svelte
├── login/
│ └── +page.svelte
├── (shop)/
│ ├── +layout.svelte
│ ├── +page.ts
│ ├── +page.svelte
│ └── products/
│ ├── +page.ts
│ └── +page.svelte
└── account/
├── +layout.svelte
├── +layout.server.ts
├── +page.server.ts
├── +page.svelte
├── orders/
│ ├── +page.server.ts
│ └── +page.svelte
└── profile/
├── +page.ts
└── +page.svelte Table of Contents
Step 1: create the project
You can reuse the shop from the routing post. To start from scratch, 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.
Step 2: a root layout
If you omit src/routes/+layout.svelte, SvelteKit uses this default:
<script>
let { children } = $props();
</script>
{@render children()} children is a snippet containing the current page or the next layout down. {@render children()} places it in the layout. Without that line, the URL still matches but the page content does not appear.
Replace src/routes/+layout.svelte. page from $app/state describes the current page. Later, child load functions will return a title. The root layout reads it through page.data because its own data prop does not contain data returned by children.
<script lang="ts">
import { page } from '$app/state';
let { children } = $props();
</script>
<svelte:head>
<title>{page.data.title ?? 'Northside Hardware'}</title>
</svelte:head>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/account">Account</a>
</nav>
{@render children()} $app/stores is the older API. Use context for something you create in the layout, such as a cart. Use page.data for values returned by load.
Replace src/routes/+page.svelte:
<h1>Northside Hardware</h1>
<p>Hammers, tape, sandpaper.</p> Create src/routes/products/+page.svelte:
<h1>Products</h1>
<p>Claw hammer, masking tape, 80 grit sandpaper.</p> Click between Home and Products. The nav stays in place while SvelteKit replaces the page content. This example leaves the markup unstyled. Tailwind in SvelteKit shows how to import CSS from the root layout.
Step 3: a nested layout
GitHub’s settings pages are separate routes with the same submenu. We can use that pattern for /account.
Create src/routes/account/+layout.svelte:
<script lang="ts">
let { children } = $props();
let note = $state('');
</script>
<h1>Account</h1>
<nav>
<a href="/account">Overview</a>
<a href="/account/orders">Orders</a>
<a href="/account/profile">Profile</a>
</nav>
<p>
<label>
Scratch pad
<input bind:value={note} />
</label>
</p>
{@render children()} Create src/routes/account/+page.svelte:
<p>Recent orders and the profile live in the submenu.</p> Create src/routes/account/orders/+page.svelte:
<p>No orders yet.</p> Create src/routes/account/profile/+page.svelte:
<p>Name and shipping address would go here.</p> Open /account, type into the scratch pad, and click Orders. The text remains because SvelteKit kept the account layout mounted and replaced only the page.
The root nav remains as well. For these routes, SvelteKit renders the root layout, the account layout, and then the page.
Step 4: load data for the layout
The hardcoded submenu works, but a layout can also load data shared by its child pages. Put that work in +layout.server.ts or +layout.ts.
+layout.server.ts runs only on the server and can read cookies, locals, or modules under $lib/server. +layout.ts is universal: it runs on the server for the first request and in the browser during later navigations, so it cannot import server modules.
Create src/routes/account/+layout.server.ts:
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = () => {
return {
customer: 'Ada',
sections: [
{ href: '/', title: 'Shop' },
{ href: '/account', title: 'Overview' },
{ href: '/account/orders', title: 'Orders' },
{ href: '/account/profile', title: 'Profile' }
]
};
}; A real app would get customer from a session. HttpOnly cookie auth uses locals.user for that and calls redirect() when the cookie is missing. Here, a fixed name keeps the example focused on layout data.
Replace src/routes/account/+layout.svelte:
<script lang="ts">
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let note = $state('');
</script>
<h1>Account</h1>
<p>Signed in as {data.customer}</p>
<nav>
{#each data.sections as section (section.href)}
<a href={section.href}>{section.title}</a>
{/each}
</nav>
<p>
<label>
Scratch pad
<input bind:value={note} />
</label>
</p>
{@render children()} LayoutProps types both data and children. It was added in SvelteKit 2.16. Older versions require you to type those fields separately.
Reload /account. You should see Ada above the submenu loaded from +layout.server.ts.
Child pages receive the same data, so they do not need to fetch the customer again.
Replace src/routes/account/profile/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<p>This profile belongs to {data.customer}.</p> SvelteKit merges the layout’s return value into the page data. If the page load also returns customer, the page’s value wins.
Step 5: a title from each page
The root layout already reads page.data.title. Return one from each page load.
Create src/routes/+page.ts. This data is public, so a universal load is enough.
import type { PageLoad } from './$types';
export const load: PageLoad = () => {
return { title: 'Northside Hardware' };
}; Create src/routes/products/+page.ts:
import type { PageLoad } from './$types';
export const load: PageLoad = () => {
return { title: 'Products' };
}; Create src/routes/account/+page.server.ts. We will use a server load here and on the orders page.
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return { title: 'Account' };
}; Create src/routes/account/orders/+page.server.ts:
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
return {
title: 'Orders',
orders: [
{ id: '1042', item: 'Claw hammer' },
{ id: '1043', item: 'Masking tape' }
]
};
}; Replace src/routes/account/orders/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<ul>
{#each data.orders as order (order.id)}
<li>{order.id}: {order.item}</li>
{/each}
</ul> Create src/routes/account/profile/+page.ts:
import type { PageLoad } from './$types';
export const load: PageLoad = () => {
return { title: 'Profile' };
}; Click through the pages and watch the browser tab title change. If you view the source of /account/orders, the initial HTML contains <title>Orders</title> because its load ran on the server.
Step 6: a page that skips the shop nav
Putting /login inside /account would give the login screen an account submenu. Create it next to account instead.
Create src/routes/login/+page.svelte:
<h1>Staff login</h1>
<p>A form would go here. For now this is only a URL.</p>
<p><a href="/">Back to the shop</a></p> Open /login. The shop nav is still there because it belongs to the root layout. You could hide it with {#if page.url.pathname !== '/login'}, but that gets tedious once several pages need different layouts. Route groups let each set of pages use its own layout without changing their URLs.
A folder in parentheses does not appear in the URL. (shop)/products/+page.svelte is still /products.
Move the public pages and their nav under (shop).
- Create
src/routes/(shop)/. - Move
src/routes/+page.svelteandsrc/routes/+page.tsinto it. - Move
src/routes/products/into it. - Create
src/routes/(shop)/+layout.svelteand cut the<nav>out of the root layout.
src/routes/+layout.svelte should now be:
<script lang="ts">
import { page } from '$app/state';
let { children } = $props();
</script>
<svelte:head>
<title>{page.data.title ?? 'Northside Hardware'}</title>
</svelte:head>
{@render children()} Create src/routes/(shop)/+layout.svelte:
<script lang="ts">
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/account">Account</a>
<a href="/login">Staff</a>
</nav>
{@render children()} /, /products, and any later /about page now use the (shop) layout. /login and /account do not. The login page no longer has the shop nav, while /account keeps its own layout inside the root. Use the Shop link in the account submenu to get back.
SPA protected routes use the same idea with (public) and (private). Parentheses only choose the layout tree; they do not hide a route. A static build still ships every page. Protect private data in a server load, an action, or hooks.server.ts.
For example, (app)/dashboard/+page.svelte and (app)/orders/+page.svelte can share a layout while keeping the URLs /dashboard and /orders. A single nested section usually does not need a group. The /account folder already limits its layout to the right pages.
Other layout files
+layout.ts and +layout.server.ts can export page options. The values of ssr, csr, and prerender then become defaults for every child. SPA protected routes set ssr = false on the public and private layouts so neither branch renders on the server.
Layouts have no effect on +server.ts. A load in +layout.server.ts does not run for an /api/... request. Check authorization inside the handler, as the guide to serving files behind auth does for GET requests.
If a layout’s load throws, SvelteKit cannot render the +error.svelte beside that layout. It looks for one above it instead. The routing post builds a root error page.
A page can skip nested layouts with +page@.svelte, which resets to the root, or +page@account.svelte, which resets to the account layout. A layout can reset its children with +layout@.svelte. This is useful for one unusual URL that does not justify another group.
When layout load runs
SvelteKit tracks the values each load reads. Moving from /account/orders to /account/profile does not rerun account/+layout.server.ts because that function does not depend on changing params or url values. The page load still runs.
Even when a load function reruns, SvelteKit updates the component’s data prop instead of recreating the component. The scratch pad therefore keeps its text.
A layout auth check does not run for every navigation within the branch. The load docs recommend a hook when the check must happen before every load, or a check in each page load that returns private data. The cookie session guide shows the pieces involved.
goto('.') with invalidateAll: true reruns every load while keeping the layout mounted. location.reload() reloads the whole document. The goto guide covers the other options.
Things that trip people up
If you forget {@render children()}, the URL matches but the page content is missing. Components have the same problem when a wrapper never renders its snippet.
src/routes/account.svelte does not create /account. The layout file must be src/routes/account/+layout.svelte.
A (private) folder changes the layout hierarchy, not access to the URL.
If login inherits the account submenu, move it outside account instead of adding pathname checks to the layout.
Do not import $lib/server from +layout.ts or +layout.svelte because both can run in the browser. Keep that import in +layout.server.ts.
If both a layout and a page return title, the page’s value wins. Return page-specific titles from the page.
Do not catch redirect() in a layout load. SvelteKit needs to receive the thrown redirect to move the user.
What to add next
For a smaller example with one root layout and a form, follow the SvelteKit tutorial. The routing post covers the other + files, including +error.svelte and +server.ts.
To keep a cart alive while navigating between / and /products, create it with context in the root layout instead of a module-level let cart.
For a server-rendered /account, HttpOnly cookies shows a session check and redirect(303) in (app)/+layout.server.ts. If the UI is static and the API lives on another origin, SPA protected routes puts the client check in +layout.ts.