SvelteKit tutorial
Aug 21, 2026 SvelteKit
Svelte compiles components. SvelteKit puts a filesystem router on top, and it can render those components on the server. A form POST lands in the same project. That is the full-stack part.
The official interactive tutorial is click-through exercises in the browser. Here we leave files on disk. The app saves a URL to a JSON file, lists the rows, opens one, and deletes it.
The form writes a file, so you need a server. You want adapter-auto (the default), adapter-node, or anything else that runs server code. adapter-static has no server, so a form POST has nowhere to land.
We will end up with these files:
src/lib/server/links.ts
src/routes/
├── +layout.svelte
├── +page.svelte
└── links/
├── +page.svelte
├── +page.server.ts
└── [id]/
├── +page.svelte
└── +page.server.ts
data/links.json # created at runtime, keep it out of git Table of Contents
Step 1: create the project
Install Node 18 or newer first. Then run the Svelte CLI:
npx sv create reading-list
cd reading-list
npm install
npm run dev When the CLI asks, pick TypeScript. Skip the add-ons. You can add Prettier, ESLint, or Tailwind later with npx sv add.
Open http://localhost:5173. Vite is the dev server. Save a file and the browser updates.
If you use VS Code, install the Svelte extension. Completions for $props and the generated ./$types module show up.
This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib and config lives on the Vite plugin. The SvelteKit 3 migration guide lists those renames.
Step 2: the files that matter
src/routes is the router. A directory is a URL.
src/routes/+page.svelteis/src/routes/links/+page.svelteis/linkssrc/routes/links/[id]/+page.svelteis/links/abc, withabcinparams.id
The + prefix marks route files. Other files in those folders are colocated modules. SvelteKit ignores them for URLs.
+page.svelte is the page. +page.server.ts runs only on the server. Put load and form actions there if you need the filesystem or a secret. +page.ts also exports load, but that function runs in the browser on client navigations, so it cannot import $lib/server.
src/lib is shared code. Import it with $lib. SvelteKit refuses to let client files import src/lib/server, so the JSON helpers stay off the bundle.
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="/links">Reading list</a>
</nav>
{@render children()} SvelteKit uses ordinary <a> tags. There is no <Link> component.
Replace src/routes/+page.svelte:
<h1>Reading list</h1>
<p>
Save links you want to read. Open
<a href="/links">the list</a>
to add one.
</p> The nav stays when you click through. Only the page body swaps.
Step 3: a store on the server
Create src/lib/server/links.ts. This is the whole backend for now. A JSON file is enough to learn load and form actions, but two POSTs at once can overwrite each other. When that starts to matter, switch to Drizzle with SQLite or Postgres. The page files stay almost the same.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const DIR = 'data';
const FILE = join(DIR, 'links.json');
export type Link = {
id: string;
title: string;
url: string;
};
async function readAll(): Promise<Link[]> {
try {
return JSON.parse(await readFile(FILE, 'utf8'));
} catch {
return [];
}
}
async function writeAll(links: Link[]) {
await mkdir(DIR, { recursive: true });
await writeFile(FILE, JSON.stringify(links, null, 2));
}
export async function listLinks(): Promise<Link[]> {
return readAll();
}
export async function getLink(id: string): Promise<Link | null> {
const links = await readAll();
return links.find((link) => link.id === id) ?? null;
}
export async function addLink(input: { title: string; url: string }): Promise<Link> {
const links = await readAll();
const link: Link = {
id: crypto.randomUUID(),
title: input.title,
url: input.url
};
links.push(link);
await writeAll(links);
return link;
}
export async function deleteLink(id: string): Promise<boolean> {
const links = await readAll();
const next = links.filter((link) => link.id !== id);
if (next.length === links.length) return false;
await writeAll(next);
return true;
} Add data to .gitignore. Committing a laptop JSON file is the same mess as committing local.db.
Step 4: list and add
Create src/routes/links/+page.server.ts. load returns data for the page. The default action runs on POST.
import { fail } from '@sveltejs/kit';
import { addLink, listLinks } from '$lib/server/links';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { links: await listLinks() };
};
function asString(value: FormDataEntryValue | null): string {
return typeof value === 'string' ? value.trim() : '';
}
function parseHttpUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.href;
} catch {
return null;
}
}
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const title = asString(data.get('title'));
const rawUrl = asString(data.get('url'));
if (!title) {
return fail(400, { message: 'Title is required', title, url: rawUrl });
}
const url = parseHttpUrl(rawUrl);
if (!url) {
return fail(400, {
message: 'URL must start with http:// or https://',
title,
url: rawUrl
});
}
await addLink({ title, url });
return { success: true };
}
}; fail sends the status and the object back to the page as form. Returning title and url lets the inputs refill after a validation error. new URL() throws on garbage. Checking for http: or https: stops javascript: and file:.
type="url" on the input is a browser hint. Validate the URL in the action anyway.
Create src/routes/links/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<h1>Reading list</h1>
<form method="POST" use:enhance>
<label>
Title
<input name="title" value={form?.title ?? ''} required />
</label>
<label>
URL
<input
name="url"
type="url"
value={form?.url ?? ''}
placeholder="https://"
required
/>
</label>
<button type="submit">Add</button>
</form>
{#if form?.message}
<p>{form.message}</p>
{/if}
<ul>
{#each data.links as link (link.id)}
<li>
<a href="/links/{link.id}">{link.title}</a>
</li>
{/each}
</ul> $props() is how Svelte 5 reads props. data comes from load. form comes from the last action on this page.
use:enhance turns the submit into a fetch so the page does not fully reload. The form still works if JavaScript is off, which is why method="POST" has to be on the element itself. After a successful action, SvelteKit reruns load, so the new row shows up without extra client code.
The (link.id) in {#each} is the key. Svelte uses it to reuse DOM nodes when the list changes. Use a stable id, not the index.
Open /links, add https://svelte.dev/docs/kit with a title, and submit. You should see the row.
Step 5: one link, then delete
[id] is a parameter. Create src/routes/links/[id]/+page.server.ts:
import { error, fail, redirect } from '@sveltejs/kit';
import { deleteLink, getLink } from '$lib/server/links';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const link = await getLink(params.id);
if (!link) {
error(404, 'Link not found');
}
return { link };
};
export const actions: Actions = {
delete: async ({ params }) => {
const removed = await deleteLink(params.id);
if (!removed) {
return fail(404, { message: 'Link not found' });
}
redirect(303, '/links');
}
}; error() throws. SvelteKit then renders the closest +error.svelte, or the default error page if you did not add one. redirect(303, ...) is POST-redirect-GET. The browser follows with GET, so a refresh does not delete again.
delete is a named action. The form posts to ?/delete. A form with no action hits default. Do not mix a default action with named ones on the same page. After a named POST, the query string stays in the URL, and the next default submit would hit the named action by accident.
Create src/routes/links/[id]/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<h1>{data.link.title}</h1>
<p>
<a href={data.link.url} rel="noreferrer">{data.link.url}</a>
</p>
{#if form?.message}
<p>{form.message}</p>
{/if}
<form method="POST" action="?/delete" use:enhance>
<button type="submit">Delete</button>
</form>
<p><a href="/links">Back to the list</a></p> rel="noreferrer" keeps the destination from seeing your Referer. The URL already passed parseHttpUrl, so it is http: or https:.
Click a title, then Delete. You should land back on /links without that row.
Step 6: a filter that stays in the browser
load is for data that lives on the server. Typing into a search box does not need a round trip. Add $state and $derived to src/routes/links/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let query = $state('');
let filtered = $derived(
data.links.filter((link) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
link.title.toLowerCase().includes(q) || link.url.toLowerCase().includes(q)
);
})
);
</script> Put a search field above the list, and iterate filtered instead of data.links:
<label>
Filter
<input type="search" bind:value={query} />
</label>
<ul>
{#each filtered as link (link.id)}
<li>
<a href="/links/{link.id}">{link.title}</a>
</li>
{/each}
</ul> $state is reactive. $derived recomputes when query or data.links changes. After enhance succeeds, load returns a new data.links and the filter still applies. The search box is outside the add form, so enhance does not reset it.
Keep the JSON reads in +page.server.ts. Keep the search string in $state.
Things that trip people up
Do not import $lib/server/links from a .svelte file or from +page.ts. +page.ts runs in the browser on navigation. Keep the JSON helpers in +page.server.ts.
If you prerender /links, load runs at build time against whatever data/links.json the build machine has. The HTML matches that snapshot. A live list needs a server at request time.
A JSON file on Vercel and most serverless hosts is ephemeral or read-only. The write fails, or the file vanishes on the next deploy. Same limit as a local SQLite file. Use a database before you ship this.
Anyone who can load /links can POST to it. If links belong to a user, check locals.user in both load and the actions the same way HttpOnly cookie auth does.
required on the input only helps when the browser cooperates. The action still has to reject empty titles and bad URLs.
What to add next
Replace the JSON helpers with Drizzle and SQLite if you want a real table on your laptop, or Postgres if you already have a server. The load and action shapes stay the same.
Put a session in front of the POST with HttpOnly cookies. Then a file upload is the same form pattern with a File instead of a string.