SvelteKit: File-Based Routing, Load Functions, Form Actions and Hooks
Key takeaways
Next.js can feel heavy for smaller apps, and SvelteKit covers the same full-stack needs with less machinery. The post explains the split between +page.ts and +page.server.ts, form actions with progressive enhancement via use:enhance, where hooks fit, and which adapter to pick.
What this post covers
This guide walks through building a full-stack app with SvelteKit: file-based routing, load functions, form actions, API routes, hooks, and deployment adapters. The code targets SvelteKit 2; where Svelte 4 and Svelte 5 syntax differ, both are noted.
Why people reach for SvelteKit
Data loading lives next to the page. In the Next.js Pages Router, getServerSideProps and client-side fetching split data logic across different mechanisms. SvelteKit has one concept — a load function in a file beside the page — and the only decision is whether that file runs on the server only or on both server and client.
Less JavaScript shipped. Svelte compiles components into direct DOM operations, so there is no virtual-DOM runtime to download. The framework runtime is small, which matters most on content-heavy pages and slow phones. The difference shrinks as an app grows, because your own component code eventually dominates the bundle; measure your real pages rather than comparing “hello world” sizes.
Forms without a separate API. Form actions let a <form method="POST"> talk directly to server code in the same route, and they keep working without JavaScript.
What is SvelteKit?
SvelteKit is the application framework for Svelte, in the same way Next.js is for React or Nuxt is for Vue. Svelte is the component compiler; SvelteKit adds routing, server-side rendering, data loading, form handling, and build/deploy integration on top of Vite.
By default a page is server-rendered on first request, then hydrated, and subsequent navigations happen on the client: SvelteKit fetches only the data for the next page and renders it in the browser. Per route, you can switch that off (export const ssr = false), prerender it at build time (export const prerender = true), or disable client-side rendering entirely (export const csr = false) for pages that need no interactivity. That per-route control is one of its practical strengths — a marketing page, a docs section, and a logged-in dashboard can each use a different strategy in the same app.
Creating a project
npx sv create my-app
cd my-app
npm install
npm run dev
sv create is the current scaffolding CLI; older tutorials use npm create svelte@latest, which has been deprecated in its favor. The wizard asks about TypeScript, ESLint, Prettier, Playwright, and Vitest, and can add integrations such as Tailwind or Drizzle later with npx sv add. The dev server runs on Vite, so the port is 5173 by default.
File-based routing
Structure
src/routes/
├── +page.svelte # /
├── about/
│ └── +page.svelte # /about
├── blog/
│ ├── +page.svelte # /blog
│ └── [slug]/
│ └── +page.svelte # /blog/:slug
└── api/
└── users/
└── +server.ts # /api/users
Every route is a directory, and the +-prefixed files inside it have fixed roles: +page.svelte is the UI, +page.ts / +page.server.ts load its data, +layout.svelte wraps child routes, +server.ts handles raw HTTP requests, and +error.svelte renders errors. Files without a + prefix are ignored by the router, so you can keep helper components right next to the route that uses them.
[slug] is a dynamic segment available as params.slug. [...rest] matches any number of segments, [[lang]] is optional, and a folder in parentheses such as (marketing) groups routes under a shared layout without adding to the URL. A common early confusion is putting about.svelte in src/routes/ expecting a /about route, as in SvelteKit’s pre-1.0 betas — nothing happens, because only about/+page.svelte counts.
Load functions
+page.ts
// src/routes/blog/+page.ts
export async function load({ fetch }) {
const response = await fetch('/api/posts');
const posts = await response.json();
return {
posts,
};
}
A +page.ts load is universal: it runs on the server during the first render, then in the browser on client-side navigation. Use the fetch passed in the argument, not the global one. SvelteKit’s version resolves relative URLs like /api/posts on the server, forwards the user’s cookies to same-origin endpoints, and — importantly — records the response during SSR and inlines it into the HTML, so the browser does not repeat the request during hydration. With the global fetch, you get a relative-URL error on the server and a duplicate request in the browser.
<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
export let data; // Svelte 4
// let { data } = $props(); // Svelte 5 equivalent
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li>
<a href="/blog/{post.slug}">{post.title}</a>
</li>
{/each}
</ul>
Whatever load returns becomes the page’s data prop, merged with the data of parent layouts. In a TypeScript project, SvelteKit generates ./$types for each route so that data is typed from what load returns — import PageData / PageLoad from there instead of writing the types yourself.
+page.server.ts
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const post = await db.post.findUnique({
where: { slug: params.slug },
});
if (!post) {
error(404, 'Post not found');
}
return { post };
}
A +page.server.ts load runs only on the server. That is where database clients, secrets, and private environment variables ($env/static/private) belong; SvelteKit refuses to build if a server-only module is imported into client code, which catches accidental secret leaks at build time. On client-side navigation, the browser calls an internal endpoint and receives the serialized result.
That serialization is the main constraint: return values must be serializable by devalue, which handles Date, Map, Set, BigInt, and repeated references, but not class instances with methods or functions. Returning an ORM model object whose prototype carries methods produces an error like “Data returned from load … is not serializable”; map it to a plain object first.
In SvelteKit 2, error() and redirect() throw by themselves, so the throw error(...) form from SvelteKit 1 is no longer needed. A related trap: calling redirect() inside a try { ... } catch block catches SvelteKit’s own redirect and swallows it. Keep redirects outside try, or rethrow with isRedirect().
Which one to choose? If the data comes from a public API and the page benefits from fetching directly from the browser on navigation, +page.ts. If it needs a DB connection or a secret, +page.server.ts. You can also have both: the server load fetches the data, and the universal load receives it as data and adds something that cannot be serialized, such as a component constructor.
Form Actions
+page.server.ts
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email || !password) {
return fail(400, { email, missing: true });
}
const user = await authenticateUser(email, password);
if (!user) {
return fail(401, { email, incorrect: true });
}
cookies.set('session', user.sessionId, { path: '/' });
redirect(303, '/dashboard');
},
};
fail() returns a response with the given status and makes its data available to the page as the form prop, which is how the error messages below get their information. Note that the password is deliberately not echoed back — only email, so the user does not have to retype it. The 303 status tells the browser to follow the redirect with a GET, which avoids the “resubmit form?” dialog if the user refreshes the dashboard.
cookies.set in SvelteKit 2 requires path explicitly (omitting it is a type error), and defaults to httpOnly: true, secure: true (except on localhost), and sameSite: 'lax' — sensible settings for a session cookie. SvelteKit also rejects cross-origin form submissions by default (a CSRF check on the Origin header), which surfaces as “Cross-site POST form submissions are forbidden” when a reverse proxy rewrites the host without setting the ORIGIN environment variable for adapter-node.
A route can define named actions instead of default (actions = { login: ..., register: ... }) and target them with <form action="?/register">. A page cannot mix default with named actions.
+page.svelte
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
export let form; // Svelte 5: let { form } = $props();
</script>
<form method="POST" use:enhance>
<input name="email" type="email" value={form?.email ?? ''} required />
<input name="password" type="password" required />
{#if form?.missing}
<p class="error">Email and password are required</p>
{/if}
{#if form?.incorrect}
<p class="error">Invalid credentials</p>
{/if}
<button type="submit">Login</button>
</form>
Without use:enhance, this is a plain HTML form: the browser posts it, the server runs the action, and the page is re-rendered with form populated. It works with JavaScript disabled or still loading, which is the “progressive enhancement” the docs refer to. With use:enhance, SvelteKit intercepts the submit, sends it with fetch, updates form, follows redirects, and re-runs load functions — without a full page reload and without scroll jumps.
The default enhance behavior resets the form on success. If you pass a callback to customize it (to show a spinner, for example), remember to call update() in the returned function, otherwise the form prop and load data are not refreshed and it looks as if the action did nothing.
API routes
// src/routes/api/users/+server.ts
import { json } from '@sveltejs/kit';
export async function GET() {
const users = await db.user.findMany();
return json(users);
}
export async function POST({ request }) {
const data = await request.json();
const user = await db.user.create({
data: {
name: data.name,
email: data.email,
},
});
return json(user, { status: 201 });
}
+server.ts exports one function per HTTP method and works with standard Request / Response objects. Use it for things forms cannot do: JSON APIs consumed by mobile apps or third parties, webhooks, file downloads, or streaming responses. For your own pages, form actions and server load functions are usually a better fit — they give you progressive enhancement, typed data, and automatic invalidation, which a hand-rolled fetch('/api/...') does not.
The POST handler picks name and email explicitly instead of passing data straight to create, which prevents a client from setting fields such as id or role. It still lacks validation; a malformed body makes request.json() throw, and SvelteKit turns that into a 500. Validate with a schema library and return error(400, ...) for bad input. If a +server.ts and a +page.svelte live in the same directory, SvelteKit routes GET requests with Accept: text/html to the page and others to the endpoint.
Hooks
hooks.server.ts
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const session = event.cookies.get('session');
if (session) {
event.locals.user = await getUserFromSession(session);
}
return resolve(event);
};
handle runs for every request the server receives — page renders, load data requests, form actions, and +server.ts endpoints — before any route code. That makes it the right place to resolve the session once and attach the result to event.locals, which is then available in every server load, action, and endpoint for that request. Declare the shape of locals in src/app.d.ts (interface Locals { user?: User }) so locals.user is typed.
Because it runs on every request, keep it cheap: a database lookup per request is normal for sessions, but calling a slow external service here adds that latency to every page, data, and API request. Multiple hooks can be combined with sequence() from @sveltejs/kit/hooks. Other hooks in the same file include handleFetch (to rewrite or add headers to server-side fetch calls) and handleError (to log unexpected errors and control what the user sees).
Usage
// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
export async function load({ locals }) {
if (!locals.user) {
redirect(303, '/login');
}
return {
user: locals.user,
};
}
A pitfall I see in almost every first SvelteKit auth setup is protecting routes only in +layout.server.ts. Layout load functions do not necessarily re-run on every navigation (only when their dependencies change), and form actions and +server.ts endpoints do not run layout loads at all. A logged-out user can therefore still POST to an action under /dashboard. Check authorization in each server load and action that needs it, or enforce it centrally in handle by matching event.url.pathname.
Deployment
SvelteKit builds through an adapter that turns the app into whatever the target platform expects. adapter-auto, which new projects use by default, detects Vercel, Netlify, Cloudflare Pages, and a few others from environment variables at build time. Installing the specific adapter is better once you know your target: builds become deterministic, and you can pass platform-specific options.
Vercel
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter(),
},
};
The Vercel adapter maps routes to serverless (or edge) functions and serves prerendered pages statically. Per-route options such as export const config = { runtime: 'edge' } let you mix runtimes.
Cloudflare Pages
npm install -D @sveltejs/adapter-cloudflare
On Cloudflare, server code runs in the Workers runtime, not Node.js. Node built-ins like fs are unavailable, and database drivers that open raw TCP sockets may not work unless they support Workers. Bindings (KV, D1, R2) arrive through event.platform.env rather than process.env.
Node.js
npm install -D @sveltejs/adapter-node
adapter-node produces a standalone server (node build) you can run in Docker or behind a reverse proxy. Set ORIGIN (or PROTOCOL_HEADER / HOST_HEADER) when running behind a proxy; otherwise SvelteKit sees http://localhost:3000 as the origin, and form actions fail the CSRF check described above. For fully static sites, adapter-static prerenders every page and needs no server at all.
Frequently asked questions (FAQ)
Q. SvelteKit vs Next.js — which should I pick?
A. Both cover SSR, static generation, API routes, and server-side mutations. SvelteKit usually ships less JavaScript and has a smaller API surface to learn. Next.js has a much larger ecosystem of React component libraries, more hiring familiarity, and deep Vercel integration. If your team already knows React and depends on React libraries, that often outweighs framework-level differences.
Q. Do I need to know Svelte first?
A. Yes — SvelteKit is built on Svelte, so components, reactivity, and props all come from Svelte. Learn which major version you are on: Svelte 5 uses runes ($state, $props), while many tutorials still show Svelte 4’s export let and $: syntax.
Q. Is it production-ready?
A. Yes. SvelteKit reached 1.0 in December 2022 and 2.0 in December 2023, and it is used in production by many teams. Upgrades between majors have been incremental, with a migration tool (npx sv migrate).
Q. Does it support TypeScript?
A. Yes. Generated ./$types modules give each route typed params, data, and form without hand-written interfaces.