SvelteKit full-stack framework: file-based routing, server and universal load functions, form actions with progressive enhancement, hooks, route groups, adapters, and REST endpoints via +server.ts
SvelteKit full-stack framework: file-based routing, server and universal load functions, form actions with progressive enhancement, hooks, route groups, adapters, and REST endpoints via +server.ts
SvelteKit Skill
When to activate
Building a full-stack SvelteKit application (not just Svelte components)
Setting up file-based routing with +page.svelte, +page.server.ts, +layout.svelte
Writing server load functions or universal load functions
Implementing form actions (default or named) with use:enhance
src/
├── routes/
│ ├── +layout.svelte # root layout — wraps all pages
│ ├── +layout.server.ts # root server load — runs on every request
│ ├── +page.svelte # / (home)
│ ├── +page.server.ts # server load + actions for /
│ ├── (auth)/ # route group — no URL segment added
│ │ ├── +layout.server.ts # guard: redirect if not logged in
│ │ ├── dashboard/
│ │ │ └── +page.svelte
│ │ └── settings/
│ │ └── +page.svelte
│ ├── blog/
│ │ ├── +page.svelte # /blog
│ │ └── [slug]/
│ │ ├── +page.svelte # /blog/[slug]
│ │ └── +page.server.ts
│ └── api/
│ └── users/
│ └── +server.ts # REST endpoint — not a page
├── lib/
│ ├── components/
│ ├── server/ # server-only imports (never sent to client)
│ │ ├── db.ts
│ │ └── auth.ts
│ └── utils.ts
└── hooks.server.ts # global server middleware
Load functions: server vs universal
// +page.server.ts — SERVER LOAD// Runs only on the server. Can access DB, secrets, cookies.// Return value is serialized and sent to the page.importtype { PageServerLoad } from'./$types'import { error } from'@sveltejs/kit'exportconstload: PageServerLoad = async ({ params, locals, cookies }) => {
if (!locals.user) error(401, 'Not authenticated')
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) error(404, 'Post not found')
return { post } // only serializable data — no class instances, no functions
}
// +page.ts — UNIVERSAL LOAD// Runs on server (initial request) AND client (navigation).// Cannot access DB or secrets directly — must call an API.importtype { PageLoad } from'./$types'exportconstload: PageLoad = async ({ fetch, params }) => {
const res = awaitfetch(`/api/posts/${params.slug}`)
if (!res.ok) thrownewError('Post not found')
return { post: await res.json() }
}
Rule: use +page.server.ts when you need DB or auth. Use +page.ts only when you have a public API and need client-side re-fetching on navigation.
<!-- +page.svelte — consuming load data -->
<script lang="ts">
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
</script>
<h1>{data.post.title}</h1>
<p>{data.post.body}</p>
Form actions
// src/routes/posts/new/+page.server.tsimporttype { Actions, PageServerLoad } from'./$types'import { fail, redirect } from'@sveltejs/kit'import { z } from'zod'constPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(10),
})
exportconstload: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(303, '/login')
return {}
}
exportconstactions: Actions = {
// Default action — called when the form has no `action` attributedefault: async ({ request, locals }) => {
if (!locals.user) returnfail(401, { message: 'Not authenticated' })
const data = Object.fromEntries(await request.formData())
const parsed = PostSchema.safeParse(data)
if (!parsed.success) {
returnfail(422, {
errors: parsed.error.flatten().fieldErrors,
values: data, // return values so the form can repopulate
})
}
const post = await db.post.create({
data: { ...parsed.data, authorId: locals.user.id },
})
redirect(303, `/blog/${post.slug}`)
},
// Named action — <form action="?/draft">draft: async ({ request, locals }) => {
const data = Object.fromEntries(await request.formData())
await db.post.create({ data: { ...data, published: false, authorId: locals.user.id } })
return { saved: true }
},
}
<!-- src/routes/posts/new/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
let { form } = $props()
</script>
<!-- use:enhance — progressive enhancement: works without JS, upgrades with it -->
<form method="POST" use:enhance>
<label>
Title
<input name="title" value={form?.values?.title ?? ''} />
{#if form?.errors?.title}
<span class="error">{form.errors.title[0]}</span>
{/if}
</label>
<label>
Body
<textarea name="body">{form?.values?.body ?? ''}</textarea>
{#if form?.errors?.body}
<span class="error">{form.errors.body[0]}</span>
{/if}
</label>
<button type="submit">Publish</button>
<button type="submit" formaction="?/draft">Save draft</button>
</form>
use:enhance intercepts the form submit, handles the response via JavaScript, and updates the form prop — no full page reload. Falls back to native form submission if JS is unavailable.
<script lang="ts">
import { page, navigating, updated } from '$app/stores'
// page — current URL, route, params, data, status, form
$: currentPath = $page.url.pathname
$: user = $page.data.user // data from root layout load
$: routeId = $page.route.id // e.g. '/blog/[slug]'
// navigating — not null while a navigation is in progress
$: isLoading = $navigating !== null
// updated — true when a new app version is deployed
// Poll: updated.check() — returns true if a new version exists
</script>
{#if $navigating}
<div class="progress-bar" />
{/if}
<nav>
<a href="/" class:active={currentPath === '/'}>Home</a>
{#if $page.data.user}
<a href="/dashboard">Dashboard</a>
{:else}
<a href="/login">Login</a>
{/if}
</nav>
User: Build an authenticated blog CRUD app in SvelteKit. Users must log in to create or edit posts. The post list is public. Use form actions with validation, not a client-side fetch.