Building apps usually starts simple: one page, one screen, one set of components. But let’s be honest — no real-world app stays that way.
Even the most basic website has multiple pages:
- Home →
/
- About →
/about
- Blog →
/blog/[slug]
- Dashboard →
/dashboard/settings
Different pages mean different layouts, different content, and sometimes different data. And if you’ve ever wired this up by hand (hello React Router configs 👋), you know it can get complicated fast: JSON route maps, nested paths, boilerplate everywhere.
That’s exactly where SvelteKit comes to the rescue.
With file-based routing, the filesystem is your router. No configs, no fuss — just drop files in src/routes
and your pages come alive.
If you’re brand new to SvelteKit, I’ve got a setup & project structure guide that walks through scaffolding a project, exploring folders, and dipping a toe into basic routing and layouts. Some of what we cover here will feel familiar — but this time we’re going deeper into the good stuff:
- Build routes using the filesystem
- Use layouts for shared UI (navbars, footers, etc.)
- Create nested routes (like
/dashboard/settings
) - Add dynamic parameters (like
/blog/[slug]
) - Handle 404s and fallbacks
🎉 Let’s dive in.
Step 1: The routes
Folder
Open a fresh SvelteKit app. Inside src/
, you’ll see a routes
folder.
This folder is special: every file inside automatically becomes a route.
For example:
src/
└─ routes/
├─ +page.svelte
├─ about/
│ └─ +page.svelte
└─ contact/
└─ +page.svelte
This gives you:
-
/
→ served byroutes/+page.svelte
-
/about
→ served byroutes/about/+page.svelte
-
/contact
→ served byroutes/contact/+page.svelte
No config files, no routing libraries. 🚀 Just files and folders.
Step 2: +page.svelte
Every page is just a Svelte component named +page.svelte
.
For example, let’s make an “About” page:
File: src/routes/about/+page.svelte
<script>
const team = $state(['Ada', 'Grace', 'Linus']);
</script>
<h1>About Us</h1>
<ul>
{#each team as member}
<li>{member}</li>
{/each}
</ul>
Now, when you visit /about
, you’ll see this page.
That’s the magic: SvelteKit takes care of wiring the route to the file automatically.
⚠️ Pitfall: Don’t rename the file. It must be +page.svelte
. If you call it About.svelte
, it won’t be routed.
👉 I recommend following along and typing this out yourself. Writing code by hand is the fastest way to lock the concepts into muscle memory — way better than just skimming.
Step 3: Layouts — Shared UI
Most apps share some common structure across pages:
- Navbars
- Footers
- Sidebars
Without layouts, you’d have to duplicate those elements in every +page.svelte
. That gets repetitive.
Instead, SvelteKit lets you define a layout with +layout.svelte
.
Example structure:
src/routes/
├─ +layout.svelte
├─ +page.svelte
├─ about/
│ └─ +page.svelte
└─ contact/
└─ +page.svelte
File: src/routes/+layout.svelte
<script>
// Svelte passes the child page into a special `children` prop
let { children } = $props();
</script>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
{@render children()}
- Everything in
+layout.svelte
renders around your pages. -
children
is the placeholder for the child page content (this is the modern replacement for<slot />
).
So now:
-
/about
→ shows the navbar + About page content. -
/contact
→ shows the navbar + Contact page content.
✅ One layout, shared across all pages.
⚠️ Pitfall: If you forget {@render children()}
, your child page won’t render at all.
Why we’re revisiting this
If you followed the setup article, layouts may look familiar. The difference here is that we’ll build on this concept — exploring scoping and nesting, which unlock patterns like dashboards with their own sidebars or sections with unique headers.
A quick note on scoping
Layouts only apply to the folder they’re in (and its children). A top-level +layout.svelte
wraps the whole app, while a +layout.svelte
inside routes/dashboard/
applies only to /dashboard/*
.
This scoped behavior is what allows nested layouts to work — stacking a global navbar with section-specific UIs.
Step 4: Nested Layouts
Sometimes a global layout isn’t enough. You might want a section-specific look — like a dashboard with its own header and menu, separate from your site-wide navbar.
The cool part? SvelteKit makes this dead simple. You just drop another +layout.svelte
inside a subfolder, and boom: nested layouts.
src/routes/
├─ +layout.svelte # global layout
├─ dashboard/
│ ├─ +layout.svelte # dashboard layout
│ ├─ +page.svelte # /dashboard
│ └─ settings/
│ └─ +page.svelte # /dashboard/settings
File: src/routes/dashboard/+layout.svelte
<script>
let { children } = $props();
</script>
<h1>Dashboard</h1>
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
</nav>
{@render children()}
Now, when you visit /dashboard/settings
, you’ll see three layers of UI stacked together:
- The global layout (e.g. your site-wide navbar).
- The dashboard layout (dashboard header + menu).
- The settings page content.
💡 This nesting can go as deep as you want. Want a settings/security/
section with its own sidebar? No problem — just add another layout.
⚠️ Pitfall: If your layouts get too deeply nested, it can feel like Russian dolls. Keep an eye on complexity — sometimes a reusable component is a better fit than yet another layout.
Step 5: Nested Routes
Layouts handle shared UI, but what about pages inside pages? That’s where nested routes come in.
Every subfolder inside src/routes/
is automatically a nested route. You don’t configure anything — folder = path.
Example structure:
src/routes/blog/
├─ +page.svelte # /blog
└─ first-post/
└─ +page.svelte # /blog/first-post
File: src/routes/blog/+page.svelte
<h1>Blog Index</h1>
<a href="/blog/first-post">Read first post</a>
File: src/routes/blog/first-post/+page.svelte
<h1>First Blog Post</h1>
<p>This is my first post!</p>
When you visit /blog/first-post
, SvelteKit stitches everything together automatically — no route config, no imports, just folders and files. 🚀
💡 Pro tip: nested routes play really well with nested layouts. For example, you can give your entire /blog/*
section a shared layout (say, a blog sidebar), and then each post page gets that for free.
⚠️ Pitfall: avoid going folder-happy. While it’s tempting to mirror your entire sitemap in folders, keep things organized but not over-engineered.
Step 6: Dynamic Routes
Static routes are great when you know them in advance (/about
, /contact
, /pricing
).
But for things like blog posts, products, or user profiles, you don’t know every possible URL ahead of time. You can’t make a new folder for every single post or user. That’s where dynamic routes save the day.
In SvelteKit, you make one by putting a folder name in square brackets:
src/routes/blog/[slug]/+page.svelte
👉 Important: you literally name the folder [slug]
.
- In the filesystem →
[slug]
is the folder name. - In the browser → you replace it with a real value, like
/blog/hello-svelte
.
File: src/routes/blog/[slug]/+page.svelte
<script>
let { params } = $props();
</script>
<h1>Blog Post: {params.slug}</h1>
Now if you visit:
-
/blog/hello-svelte
→params.slug === "hello-svelte"
-
/blog/my-second-post
→params.slug === "my-second-post"
✅ With one file, you just unlocked an infinite number of routes.
💡 Pro tip: You can even chain multiple params:
src/routes/blog/[year]/[slug]/+page.svelte
Which maps to URLs like /blog/2025/hello-svelte
.
⚠️ Don’t overuse dynamic routes — if you only ever need /about
and /contact
, just make static files.
Step 7: Loading Data in SvelteKit
A page without data is like a sandwich without filling — looks fine, but it won’t satisfy anyone. 🥪
That’s why in SvelteKit, data loading is built into the routing system.
Next to your +page.svelte
, you can add a +page.js
(or +page.server.js
). Inside, you define a load
function. Whatever load
returns becomes available inside your page as the data
prop.
Example:
File: src/routes/profile/+page.js
export async function load() {
return {
user: { name: "Ada Lovelace", bio: "Mathematician & first programmer" }
};
}
File: src/routes/profile/+page.svelte
<script>
let { data } = $props();
</script>
<h1>{data.user.name}</h1>
<p>{data.user.bio}</p>
When you open /profile
, you’ll see Ada’s info — no extra fetch boilerplate, no manual prop passing.
Why this is awesome:
- Universal by default → runs on the server first, then in the client when navigating.
- SEO + performance friendly → pages ship fully rendered.
- Clean separation → UI in one file, data logic in another.
👉 We’ll later explore when to use +page.js
vs +page.server.js
. For now, just know: load
is how data flows into your pages.
Step 8: Universal load
— Runs on Server and Client
Now let’s combine what we’ve learned so far:
- Dynamic routes (Step 6)
- Data loading (Step 7)
We’ll build a mini blog with two pages:
-
/blog
→ shows a list of posts. -
/blog/[slug]
→ shows an individual post.
File Structure
src/routes/blog/
├─ +page.svelte # blog index page
├─ +page.js # loads list of posts
└─ [slug]/ # dynamic route for individual posts
├─ +page.svelte # single post page
└─ +page.js # loads one post
Blog Index Page
File: src/routes/blog/+page.js
export async function load() {
return {
posts: [
{ slug: "hello-svelte", title: "Hello Svelte" },
{ slug: "my-second-post", title: "My Second Post" }
]
};
}
File: src/routes/blog/+page.svelte
<script>
let { data } = $props();
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li><a href={`/blog/${post.slug}`}>{post.title}</a></li>
{/each}
</ul>
Visiting /blog
now shows a list of links like:
- “Hello Svelte” →
/blog/hello-svelte
- “My Second Post” →
/blog/my-second-post
Individual Post Page
File: src/routes/blog/[slug]/+page.js
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const posts = {
"hello-svelte": { title: "Hello Svelte", content: "This is my first post." },
"my-second-post": { title: "My Second Post", content: "Here’s some more content." }
};
const post = posts[params.slug];
if (!post) {
throw error(404, `Post "${params.slug}" not found`);
}
return { post };
}
File: src/routes/blog/[slug]/+page.svelte
<script>
let { data } = $props();
</script>
<article>
<h2>{data.post.title}</h2>
<p>{data.post.content}</p>
</article>
Here’s what’s happening:
- The
[slug]
folder means the URL segment is dynamic. - Visiting
/blog/hello-svelte
makesparams.slug === "hello-svelte"
. - The loader uses that slug to fetch the right post.
- If the slug doesn’t exist, we
throw error(404)
to show a not-found page instead of crashing.
How to Test It 🚦
- Start your dev server:
npm run dev
Visit
/blog
→ you’ll see the list of posts.Click a link → you’ll see the full post.
- On the first load,
load
runs on the server, so the HTML already contains the post content → faster first paint + SEO-friendly. - On client-side navigation, the same
load
runs in the browser usingfetch
.
- Try visiting
/blog/not-a-post
→ you’ll get a proper 404 instead of a 500.
✨ That’s universal loading in action: one load
function, and SvelteKit decides whether it runs server-side or client-side depending on how the user navigates.
Step 9: Server-Only Loading 🔒
Sometimes you really do need secrets — like database queries, authentication, or API keys. That’s where +page.server.js
comes in.
This works just like +page.js
, but with one crucial difference: it only ever runs on the server. That means the code never reaches the browser.
Example
🧹 Quick cleanup: If you’ve just done Step 8, you already have a
+page.js
inside/blog/[slug]
. Before trying this step, remove that file (or rename it) and replace it with+page.server.js
. Otherwise, SvelteKit will ignore the universal loader and only run the server one.
File: src/routes/blog/[slug]/+page.server.js
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const posts = {
"hello-svelte": {
title: "Hello Svelte",
content: "This post is loaded server-side only. Imagine pulling it from a database 🔒"
},
"my-second-post": {
title: "My Second Post",
content: "Another server-only post. Safe for secrets, safe for API keys."
}
};
const post = posts[params.slug];
if (!post) {
throw error(404, `Post "${params.slug}" not found`);
}
return { post };
}
- Notice how we’re serving different content depending on the slug.
- And since this runs only on the server, you could safely query a DB or use environment variables here.
Using the data in the page
Whatever you return from +page.server.js
is available in the page as the data
prop — exactly the same as before.
File: src/routes/blog/[slug]/+page.svelte
<script>
let { data } = $props();
</script>
<article>
<h1>{data.post.title}</h1>
<p>{data.post.content}</p>
</article>
Now:
-
/blog/hello-svelte
→ shows “Hello Svelte” with custom content. -
/blog/my-second-post
→ shows “My Second Post” with its own text. -
/blog/not-a-post
→ gives you a proper 404.
Why use +page.server.js
here?
- Protecting secrets → fetching blog posts from an API with a private key? This keeps it safe.
- Server capabilities → connect to databases, check user sessions, or use environment variables.
-
Same flow as before → whatever you return from
load
still becomes available to your page asdata
.
Key difference vs. +page.js
-
+page.js
→ runs on both server (first load) and client (navigations). -
+page.server.js
→ runs only on the server.
Think of it like this:
-
Public data →
+page.js
-
Private or sensitive data →
+page.server.js
⚠️ Pitfall: Don’t try to use browser-only stuff (window
, document
, localStorage
, etc.) inside +page.server.js
. They don’t exist on the server.
👉 This way, you’ve now seen both sides: universal loaders for public data (Step 8), and server-only loaders for protected data (Step 9).
Step 10: Props vs data
Whatever your load
function returns is automatically passed into your page as a special prop called data
. You don’t import it, you don’t pass it manually — SvelteKit wires it up for you.
Example:
// inside +page.js or +page.server.js
export async function load() {
return {
user: { name: 'Ada' }
};
}
File: src/routes/profile/+page.svelte
<script>
let { data } = $props();
</script>
<p>Hello {data.user.name}</p>
Destructuring for convenience
If you only need part of the object, you can pull it out right away:
<script>
let { data } = $props();
const { user } = data;
</script>
<p>Hello {user.name}</p>
This is just shorthand. Both versions work the same — destructuring just saves a bit of typing when you’re accessing the same field multiple times.
Why this matters
-
data
is the bridge between your loader and your page. - You never import things directly from
+page.js
or+page.server.js
into your component — SvelteKit takes care of that. - This keeps your UI and data fetching logic separate, which makes your app easier to maintain and reason about.
💡 Pro tip: If your data
object starts looking like a giant “junk drawer” of unrelated values, that’s usually a sign you should split the page into smaller routes or use multiple loaders.
Step 11: The Special fetch
Normally, fetching data in JavaScript means juggling different environments:
- In the browser, you’d use
window.fetch
. - On the server, you’d need something like
node-fetch
.
That’s a pain to maintain.
👉 Inside SvelteKit’s load
function, you get a special fetch
that works the same everywhere.
- On the server → it behaves like
node-fetch
. - On the client → it behaves like the browser’s
fetch
.
This makes your data loading code universal and portable — you write it once, and SvelteKit takes care of the environment.
Example:
File: src/routes/products/+page.js
export async function load({ fetch }) {
const res = await fetch('/api/products.json');
const products = await res.json();
return { products };
}
Now whether the page is first rendered on the server or navigated to in the client, it’ll Just Work™.
✅ You don’t need to worry about environment differences — the fetch
is already adapted for you.
💡 Pro tip: always use this provided fetch
inside load
. It plays nicely with SvelteKit features like credentials, relative paths, and even preloading.
Step 12: Error Handling in load
Of course, not every fetch succeeds. APIs fail, typos happen, and sometimes pages just don’t exist.
In SvelteKit, the way to handle errors is to throw an error inside your load
function.
import { error } from '@sveltejs/kit';
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}.json`);
if (!res.ok) {
throw error(404, 'Post not found');
}
return { post: await res.json() };
}
Here’s what happens:
- If the fetch fails, we
throw error(404, 'Post not found')
. - SvelteKit immediately stops the loader and shows an error page.
- By default, it’ll use the global error page.
- But if you put a
+error.svelte
file in the same folder, SvelteKit will use that instead — so you can show a nice custom message.
✅ This means you don’t have to handle errors manually in your component — the framework has your back.
⚠️ Pitfall: don’t try to return an error object yourself (like { error: 'Not found' }
). Unless you throw, SvelteKit won’t know to treat it as an actual error page.
Step 14: Universal vs Server load
— When to Use Which?
Okay, we’ve seen two flavors of load
:
-
Universal (
+page.js
) → runs on server and client. -
Server-only (
+page.server.js
) → runs only on the server.
So… when do you pick one over the other?
Here’s a simple rule of thumb:
File: src/routes/blog/[slug]/+page.js
// Universal load
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}.json`);
return { post: await res.json() };
}
Use +page.js
when:
- The data is safe for the client.
- You want the same fetch logic to work both server-side and client-side.
- Example: blog posts from a public API.
File: src/routes/dashboard/+page.server.js
// Server-only load
export async function load({ locals }) {
// Pretend this checks a logged-in user from the session
if (!locals.user) {
return { user: null };
}
return { user: locals.user };
}
Use +page.server.js
when:
- The data is sensitive (like a database query, user info, or private API key).
- You need server-only logic (like authentication checks).
- Example: dashboards, admin pages, or user profiles.
👉 Think of it like this:
-
+page.js
→ public data (okay for anyone to see). -
+page.server.js
→ private data (keep it on the server).
⚠️ Pitfall: if you accidentally put secret logic in +page.js
, it’ll run in the browser too — which means users can peek at it. Always ask yourself: should the client be allowed to see this code?
Wrapping Up 🎁
And that’s it! You’ve just leveled up from single-page Svelte toys to building real multi-page apps with SvelteKit. 🚀
Here’s what you can now do with confidence:
- Create routes just by making files (the filesystem is the router).
- Share UI across pages with layouts.
- Nest layouts and routes as deep as you like.
- Use dynamic routes to handle flexible URLs like
/blog/[slug]
. - Fetch data with both universal loaders and server-only loaders.
- Handle errors, redirects, and pass data cleanly into your pages.
At this point, you’ve basically got all the routing + data loading superpowers you need to build serious apps. 💪
But apps aren’t just about reading data — we also need to create, update, and delete stuff. That’s where Forms & Actions come in, and SvelteKit makes them just as seamless (and progressively enhanced by default).
👉 Stay tuned for the next part of this series:
“SvelteKit Forms & Actions — Progressive Enhancement the Easy Way”
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Checkout my offering on YouTube with (growing) crash courses and content on JavaScript, React, TypeScript, Rust, WebAssembly, AI Prompt Engineering and more: @LearnAwesome
Top comments (0)