SvelteKit’s file-based routing and layout system are the real headliners, not the framework’s ability to scaffold a project. In this tour, we’ll build the shell of a to-do app and hit the framework’s core concepts: nested and grouped layouts, server-side load() functions, and form actions that automatically re-fetch your page data after a mutation.

For this walkthrough, we’ll be using static data and in-memory updates. The point isn’t to build a production app; it’s to see how the framework’s data-loading and mutation machinery fits together, so you can swap in your real database calls later.

All code is available on GitHub, and a live demo is deployed on Vercel.

Scaffolding the project

Create a new project with npm create svelte@latest your-app-name. The prompts will ask about TypeScript, ESLint, and similar options; choose the “Skeleton Project” base. Once set up, run npm i and npm run dev to start the dev server, typically at localhost:5173. We’ll just be building pages and wiring them up from there.

Routing basics

All routes are code under src/routes, where a file named +page.svelte defines the page for its directory’s URL path. A +page.svelte in the root of that folder is your / page; create further directories for additional paths. For an app with the routes /list, /details, /admin/user-settings, and /admin/paid-status, your folder structure should look like this:

Initial files.

The URLs are directly visitable in the browser’s address bar:

Browser address bar with localhost URL.

Building the app shell with a layout

Adding navigation links to each page individually would get repetitive fast. Instead, create a +layout.svelte in the root of routes to serve as the shell for every page:

<nav>
  <ul>
    <li>
      <a href="/">Home</a>
    </li>
    <li>
      <a href="https://css-tricks.com/list">To-Do list</a>
    </li>
    <li>
      <a href="https://css-tricks.com/admin/paid-status">Account status</a>
    </li>
    <li>
      <a href="https://css-tricks.com/admin/user-settings">User settings</a>
    </li>
  </ul>
</nav>

<slot />

<style>
  nav {
    background-color: beige;
  }
  nav ul {
    display: flex;
  }
  li {
    list-style: none;
    margin: 15px;
  }
  a {
    text-decoration: none;
    color: black;
  }
</style>

That file puts navigation markup and basic styles in one spot. The important bit is the <slot /> tag, which marks the insertion point for whichever page SvelteKit is rendering. This isn’t the web-component slot—it’s a Svelte feature that lets the current page’s content render inside the layout’s structure.

Horizontal navigation with light yellow background.

Managing sections with nested layouts

For pages that need to inherit the global layout but also share their own UI—say, all pages under /admin get a red banner—you add another +layout.svelte inside that section:

<div>This is an admin page</div>

<slot />

<style>
  div {
    padding: 15px;
    margin: 10px 0;
    background-color: red;
    color: white;
  }
</style>

The global layout renders, the nested /admin layout’s content replaces the global layout’s slot, and finally the page itself renders into the nested layout’s own <slot />:

Red box beneath navigation that says this is an admin page.

Loading and sharing data

Now to bring actual content into those routes. Real applications will query a database; we’ll simulate with lib/data/todoData.ts. The lib folder is available project-wide through the $lib alias.

let todos = [
  { id: 1, title: "Write SvelteKit intro blog post", assigned: "Adam", tags: [1] },
  { id: 2, title: "Write SvelteKit advanced data loading blog post", assigned: "Adam", tags: [1] },
  { id: 3, title: "Prepare RenderATL talk", assigned: "Adam", tags: [2] },
  { id: 4, title: "Fix all SvelteKit bugs", assigned: "Rich", tags: [3] },
  { id: 5, title: "Edit Adam's blog posts", assigned: "Geoff", tags: [4] },
];

let tags = [
  { id: 1, name: "SvelteKit Content", color: "ded" },
  { id: 2, name: "Conferences", color: "purple" },
  { id: 3, name: "SvelteKit Development", color: "pink" },
  { id: 4, name: "CSS-Tricks Admin", color: "blue" },
];

export const wait = async amount => new Promise(res => setTimeout(res, amount ?? 100));

export async function getTodos() {
  await wait();

  return todos;
}

export async function getTags() {
  await wait();

  return tags.reduce((lookup, tag) => {
    lookup[tag.id] = tag;
    return lookup;
  }, {});
}

export async function getTodo(id) {
  return todos.find(t => t.id == id);
}

That data module exports a flat array of to-do items, a lookup of tags, and a function to fetch a single to-do item. The load() function runs server-side to pull this data into a page. Our List’s +page.server.ts looks like:

import { getTodos, getTags } from "$lib/data/todoData";

export function load() {
  const todos = getTodos();
  const tags = getTags();

  return {
    todos,
    tags,
  };
}

Note the deliberate lack of await before those async calls. Returning the raw promises lets SvelteKit load the data in parallel rather than sequentially—no waterfall delay. That handler’s return value lands on the page’s data prop, accessed in the Svelte component through reactive assignments:

<script>
  export let data;
  $: ({ todo, tags } = data);
</script>

<table cellspacing="10" cellpadding="10">
  <thead>
    <tr>
      <th>Task</th>
      <th>Tags</th>
      <th>Assigned</th>
    </tr>
  </thead>
  <tbody>
    {#each todos as t}
    <tr>
      <td>{t.title}</td>
      <td>{t.tags.map((id) => tags[id].name).join(', ')}</td>
      <td>{t.assigned}</td>
    </tr>
    {/each}
  </tbody>
</table>

<style>
  th {
    text-align: left;
  }
</style>

Renders everything:

Five to-do items in a table format.

Sharing with layout groups

Nested layouts work for pages sharing a URL prefix, but not every collection of route files falls neatly into one tree. Layout groups solve that. Creating a directory with a name enclosed in parentheses lets you declare a layout for an arbitrary set of sibling pages:

File directory.

Any +layout.svelte inside a (layout-group) directory applies to pages beneath it, but that directory’s name doesn’t appear in any URL. Within the group, the List and Details pages will now share any +layout.svelte and its loaders without needing to be siblings of a physical folder.

In our demo, we’ll remove the tags loading from the List’s page loader and promote it to a shared +layout.server.ts in the group’s root instead, so Details can also rely on tags.

import { getTodos, getTags } from "$lib/data/todoData";

export function load() {
  const todos = getTodos();

  return {
    todos,
  };
}
import { getTags } from "$lib/data/todoData";

export function load() {
  const tags = getTags();

  return {
    tags,
  };
}

SvelteKit then handles merging the data from separate load() functions—one defined in the layout group, one in the List’s own server file—and exposes both in the single data prop.

Editing data across pages

To get from the List page to editing, a link needs to carry the to-do ID:

<td><a href="https://css-tricks.com/details?id={t.id}">Edit</a></td>

The Details page needs its own loader to fetch that item:

import { getTodo, updateTodo, wait } from "$lib/data/todoData";

export function load({ url }) {
  const id = url.searchParams.get("id");

  console.log(id);
  const todo = getTodo(id);

  return {
    todo,
  };
}

We read the id out of the url.current query string. The form below edits a single to-do item. It pulls together two data streams—the tags from the layout group’s loader, and the specific item from Details’ loader. Note the ?/editTodo action and use:enhance on the form to submit via Ajax:

<script>
  import { enhance } from "$app/forms";

  export let data;

  $: ({ todo, tags } = data);
  $: currentTags = todo.tags.map(id => tags[id]);
</script>

<form use:enhance method="post" action="?/editTodo">
  <input name="id" type="hidden" value="{todo.id}" />
  <input name="title" value="{todo.title}" />

  <div>
    {#each currentTags as tag}
    <span style="{`color:" ${tag.color};`}>{tag.name}</span>
    {/each}
  </div>

  <button>Save</button>
</form>

The actual mutation lives in a form action added to the +page.server.ts file. Actions receive a request argument containing formData—our form’s inputs. The hidden ID field there gives everything mutable context. The action looks up the right item, simulates a fetch or write, and then redirects back to /list:

import { redirect } from "@sveltejs/kit";

// ...

export const actions = {
  async editTodo({ request }) {
    const formData = await request.formData();

    const id = formData.get("id");
    const newTitle = formData.get("title");

    await wait(250);
    updateTodo(id, newTitle);

    throw redirect(303, "/list");
  },
};
export async function updateTodo(id, newTitle) {
  const todo = todos.find(t => t.id == id);
  Object.assign(todo, { title: newTitle });
}

Thinking about how you structure data fetching is familiar, but SvelteKit expects that thinking to happen in load() functions that run on the server.

Observing the update loop

Go to the List page:

List page with to-do-items.

Clicking a to-do entry’s Edit button takes you to the Details editor:

Details page for a to-do item.

Changing the title in that form:

Changing the to-do title in an editable text input.

On save, the List page refreshes with brand-new data. The page instantly reflects the edit:

The edited to-do item in the full list view.

The key detail there is that SvelteKit automatically re-runs all current load() functions after the form action finishes, not just the one tied to the edited data. It makes data freshness a default behavior rather than a separate step.

A few questions typically pop up around that behavior

  • It’s fine that load() runs on navigation, but what if we stay o the same page for an update that doesn’t instantly redirect? SvelteKit reruns every loader for the page you’re currently on.
  • What about targeted invalidation? A use:enhance with a custom callback gives you direct access to invalidation functions if you must avoid re-querying data that was untouched by a mutation.
  • Anything like Query Client or react-query for caching? They follow the same logic: setting cache-control headers that SvelteKit honors.

Next steps

Those are the core web app development flow patterns in play. For a more complete look at the framework’s kitchen sink, the official SvelteKit docs are the reference. There’s also a SvelteKit 1.0 announcement post and the Vercel SvelteKit course to go deeper on deploying and scaling beyond local development.