SvelteKit: Building Full-Stack Svelte Apps
You build a nice little Svelte app, and then the real requirements show up. It needs proper URLs, pages that search engines can read, a place to put database queries that never reach the browser, and a form that still works when JavaScript fails to load. Plain Svelte doesn't answer any of that, so you either bolt on a router, an Express server and a build setup yourself, or you reach for SvelteKit.
SvelteKit is Svelte's official app framework, roughly what Next.js is to React or Nuxt is to Vue. It runs on Vite, and because Svelte compiles components away at build time, there's no big runtime library shipped to the browser. This post walks through how it actually fits together: file-based routing, load functions, API routes, form actions and deployment adapters, plus a couple of gotchas that tend to bite people in their first real project.
Svelte compiles components to plain JavaScript that updates the DOM directly, so there's no virtual DOM diffing at runtime. In practice that usually means less JavaScript per page than a comparable React app, though how much depends heavily on what libraries you pull in. Pages are server-rendered by default, so users see content before any JavaScript runs, and client-side navigation can prefetch the next page when a user hovers a link.
What SvelteKit Adds on Top of Svelte
Svelte on its own gives you reactive components. SvelteKit fills in the parts you need to ship an actual application:
- File-based routing: every file in
src/routesmaps to a URL, with no router config to maintain - Server-side rendering (SSR): pages are rendered on the server by default for fast first-paint and SEO
- Static site generation (SSG): pre-render pages at build time with the static adapter
- Server-only load functions: fetch data from databases and call secrets-protected APIs before the page renders
- API routes: build backend endpoints alongside your frontend without a separate server
- Form actions: handle form submissions server-side, with progressive enhancement so forms work without JavaScript
- Adapter system: one codebase deploys to Node.js, Vercel, Netlify, Cloudflare Workers, and more
- TypeScript first: TypeScript is configured automatically during project creation
| Feature | SvelteKit | Next.js | Nuxt |
|---|---|---|---|
| Base framework | Svelte | React | Vue 3 |
| Build tool | Vite | Turbopack / Webpack | Vite / Nitro |
| Bundle approach | Compiled (no runtime) | Runtime (React ~45 KB) | Runtime (Vue ~35 KB) |
| SSR default | Yes | Yes (App Router) | Yes |
| Data loading | +page.server.js | Server Components | useFetch / useAsyncData |
| API routes | +server.js | app/api/route.js | server/api/ |
| TypeScript support | First-class | First-class | First-class |
Installation and Project Setup
The commands below use npm create svelte@latest, which walks you through a short interactive wizard. You'll need Node.js 18 or later.
On current versions, npm create svelte@latest just prints a deprecation notice telling you to use the new CLI instead. If that happens, run npx sv create my-sveltekit-app. It asks the same kind of questions and produces the same project layout shown below.
Scaffold a new project
Run the create command, choose a template, and select TypeScript support and any optional tooling (ESLint, Prettier, Playwright, Vitest).
npm create svelte@latest my-sveltekit-app
# Interactive prompts:
# ✔ Which Svelte app template? › Skeleton project
# ✔ Add type checking with TypeScript? › Yes, using TypeScript syntax
# ✔ Select additional options: › ESLint, Prettier, Vitest
cd my-sveltekit-app
npm install
npm run dev
Explore the project structure
All routing lives in src/routes. Shared utilities and components go in src/lib and are importable via the $lib alias.
my-sveltekit-app/
├── src/
│ ├── routes/
│ │ ├── +layout.svelte # Root layout (nav, footer)
│ │ ├── +page.svelte # Home page → /
│ │ ├── blog/
│ │ │ ├── +page.svelte # Blog list → /blog
│ │ │ └── [slug]/
│ │ │ ├── +page.svelte # Blog post → /blog/:slug
│ │ │ └── +page.server.ts # Server-side data loading
│ │ └── api/
│ │ └── posts/
│ │ └── +server.ts # API route → /api/posts
│ ├── lib/
│ │ ├── components/ # Shared Svelte components
│ │ └── server/ # Server-only utilities (DB, etc.)
│ └── app.html # HTML shell template
├── static/ # Static assets (images, fonts)
├── svelte.config.js # SvelteKit configuration
└── vite.config.ts # Vite configuration
Configure your environment
Create a .env file for secrets. Variables prefixed with PUBLIC_ are exposed to the browser; all others remain server-only and are accessible via $env/static/private.
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
JWT_SECRET="your-secret-key-never-expose-this"
PUBLIC_API_BASE="https://api.myapp.com"
File-Based Routing
There's no router config file. Every directory inside src/routes is a route segment, and the files inside it decide what happens at that URL. Route files get a + prefix so they don't get mixed up with regular components you keep next to them:
| File name | Purpose | Runs on |
|---|---|---|
+page.svelte |
Page UI component | Client + server (SSR) |
+layout.svelte |
Shared wrapper around child routes | Client + server (SSR) |
+page.server.ts |
Server-only load function & form actions | Server only |
+page.ts |
Universal load function | Server (SSR) + client (navigation) |
+server.ts |
REST API endpoint | Server only |
+error.svelte |
Custom error page | Client + server (SSR) |
Dynamic Routes and Route Groups
Wrap a segment in square brackets to capture a dynamic value: src/routes/blog/[slug] matches /blog/any-slug-here. Use spread syntax for catch-all routes: [...path]. Wrap a segment in parentheses to create a route group that shares a layout without affecting the URL: (marketing), (app).
SvelteKit routes map directly to the file system in src/routes/
A root layout wraps every page with navigation. Below it is a home page that reads the data prop coming from a load function:
<script lang="ts">
import { page } from '$app/stores';
</script>
<nav>
<a href="/" class:active={$page.url.pathname === '/'}>Home</a>
<a href="/blog" class:active={$page.url.pathname.startsWith('/blog')}>Blog</a>
<a href="/about" class:active={$page.url.pathname === '/about'}>About</a>
</nav>
<main>
<slot />
</main>
<footer>
<p>© 2026 My SvelteKit App</p>
</footer>
<style>
nav a.active { color: #FF3E00; font-weight: 600; }
</style>
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let count = 0;
const increment = () => count++;
</script>
<svelte:head>
<title>{data.siteTitle}</title>
</svelte:head>
<h1>Welcome to {data.siteTitle}</h1>
<p>Posts published: {data.postCount}</p>
<button on:click={increment}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
Loading Data with Load Functions
Data fetching doesn't live inside your components. Instead, each page can have a load function that runs before the page renders, grabs what the page needs, and returns it as the data prop on +page.svelte. You get two flavors:
- +page.server.ts: runs only on the server. It can import database clients, read secret environment variables and call any Node.js API. Whatever it returns is serialized and sent to the client.
- +page.ts: a "universal" load that runs on the server during SSR and again in the browser on client-side navigation. Use it when all you need is
fetchand nothing server-only.
One error you'll probably hit early: Data returned from `load` ... is not serializable. Server load data goes over the wire with devalue, which handles dates, Maps and Sets, but not class instances or functions. Prisma's Decimal fields and ORM model objects with methods are the usual culprits. Convert them to plain values (Number(price), toString()) before returning.
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true }
});
if (!post) {
// SvelteKit renders +error.svelte with this message
throw error(404, `Post "${params.slug}" not found`);
}
return {
post,
// Only serializable data reaches the client
meta: {
title: post.title,
description: post.excerpt
}
};
};
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
const { post, meta } = data;
</script>
<svelte:head>
<title>{meta.title}</title>
<meta name="description" content={meta.description}>
</svelte:head>
<article>
<h1>{post.title}</h1>
<p class="byline">
By {post.author.name} •
{new Date(post.publishedAt).toLocaleDateString()}
</p>
<div class="content">{@html post.htmlContent}</div>
<ul class="tags">
{#each post.tags as tag}
<li><a href="/blog?tag={tag.slug}">{tag.name}</a></li>
{/each}
</ul>
</article>
data Breaks on NavigationWhen you click from /blog/post-a to /blog/post-b, SvelteKit reuses the same page component and just hands it new data. The const { post, meta } = data; line above runs only once, so the URL changes but the old post stays on screen. Use a reactive statement instead ($: ({ post, meta } = data); in Svelte 4, or $derived in Svelte 5), or reference data.post directly in the markup.
Caching and Revalidation
Load functions can set HTTP cache headers so a CDN can cache the rendered page. Call setHeaders inside the load function to control Cache-Control. On the client, invalidate() and invalidateAll() force load functions to rerun when you know the data has changed.
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';
export const load: PageServerLoad = async ({ setHeaders }) => {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { publishedAt: 'desc' },
select: { id: true, title: true, slug: true, excerpt: true, publishedAt: true }
});
// Cache the page at the CDN for 60 seconds, allow stale for 300 s
setHeaders({
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
});
return { posts };
};
Since +page.server.ts load functions only run on the server, window, document, localStorage, and sessionStorage do not exist. For universal load functions (+page.ts) that run on both, guard browser-only calls with the browser boolean from $app/environment: if (browser) { ... }.
API Routes and Form Actions
For writes, you have two options. API routes are +server.ts files that answer HTTP requests directly, which is what you want for a mobile app, a webhook or any non-HTML client. Form actions live in +page.server.ts and handle HTML form posts from your own pages.
API Routes with +server.ts
Export functions named after HTTP verbs. The json() helper sets the Content-Type and serializes the body for you:
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import type { RequestHandler } from './$types';
// GET /api/posts?page=1&limit=10
export const GET: RequestHandler = async ({ url }) => {
const page = Number(url.searchParams.get('page') ?? '1');
const limit = Number(url.searchParams.get('limit') ?? '10');
const [posts, total] = await Promise.all([
db.post.findMany({
where: { published: true },
skip: (page - 1) * limit,
take: limit,
orderBy: { publishedAt: 'desc' }
}),
db.post.count({ where: { published: true } })
]);
return json({ posts, total, page, pages: Math.ceil(total / limit) });
};
// POST /api/posts
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user?.isAdmin) throw error(403, 'Forbidden');
const body = await request.json();
if (!body.title || !body.content) {
throw error(400, 'title and content are required');
}
const post = await db.post.create({
data: {
title: body.title,
content: body.content,
slug: body.title.toLowerCase().replace(/\s+/g, '-'),
authorId: locals.user.id
}
});
return json(post, { status: 201 });
};
Form Actions and Progressive Enhancement
If the mutation starts from a form on your own page, actions are less work than an API route. Without JavaScript they behave like a normal form post with a full page reload. Add use:enhance and the same form submits via fetch, with no extra code on the server.
A gotcha with the action below: redirect() works by throwing. If you wrap your database call and the redirect in one try/catch, your catch block swallows the redirect and the user never leaves the page. Keep the redirect outside the try block.
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import type { Actions } from './$types';
export const actions: Actions = {
// matches action="?/create" on the form
create: async ({ request, locals }) => {
if (!locals.user) throw redirect(303, '/login');
const data = await request.formData();
const title = data.get('title')?.toString().trim();
const content = data.get('content')?.toString().trim();
if (!title || title.length < 3) {
return fail(400, {
error: 'Title must be at least 3 characters',
fields: { title, content }
});
}
await db.post.create({
data: { title, content, authorId: locals.user.id }
});
throw redirect(303, '/blog');
}
};
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<!-- use:enhance upgrades to fetch-based submission -->
<form method="POST" action="?/create" use:enhance>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<label>
Title
<input
name="title"
value={form?.fields?.title ?? ''}
required minlength="3"
>
</label>
<label>
Content
<textarea name="content">{form?.fields?.content ?? ''}</textarea>
</label>
<button type="submit">Publish</button>
</form>
A page request: route matched, server load called, page rendered, HTML returned, then hydrated in the browser
Deploying SvelteKit with Adapters
An adapter takes SvelteKit's build output and turns it into whatever the target platform expects: a Node server, serverless functions, or a folder of static files. You install one package and point svelte.config.js at it.
adapter-auto detects your deployment platform automatically (Vercel, Netlify, Cloudflare, etc.) and applies the matching adapter. Fine for getting started, but once you know where you're deploying, switch to the specific adapter so you can set its options.
npm install -D @sveltejs/adapter-auto
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
export default {
kit: { adapter: adapter() }
};
adapter-node produces a standalone Node.js server. This is the one I'd use for Docker, a VPS, or anywhere you run your own servers. Behind a reverse proxy, set the ORIGIN environment variable (for example ORIGIN=https://myapp.com), otherwise form posts fail with "Cross-site POST form submissions are forbidden".
npm install -D @sveltejs/adapter-node
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({
out: 'build', // output directory
precompress: true // gzip + brotli static assets
})
}
};
// Build and run:
// npm run build
// node build/index.js
adapter-vercel deploys pages as serverless or edge functions. You can choose the edge runtime per route, which helps latency but limits you to edge-compatible libraries.
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter({
runtime: 'edge' // or 'nodejs20.x'
})
}
};
// Per-route override in +page.server.ts:
export const config = { runtime: 'nodejs20.x' };
adapter-static pre-renders your site to static HTML at build time. Good for blogs, docs and marketing sites where nothing needs a server at request time. Server load functions run at build time only, and form actions won't work at all.
npm install -D @sveltejs/adapter-static
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html' // for SPAs with client-side routing
}),
prerender: {
entries: ['*'] // pre-render all discovered pages
}
}
};
Building and Previewing
# Development server with HMR
npm run dev
# Type-check without building
npm run check
# Production build
npm run build
# Preview the production build locally (uses adapter-node or adapter-auto)
npm run preview
When I'd Use SvelteKit (and When I Wouldn't)
SvelteKit is a good fit when one small team owns both the frontend and a modest backend: content sites, dashboards, internal tools, SaaS apps where the "API" is mostly there to serve your own pages. Load functions, form actions and the adapter system cover that shape of app with very little glue code, and the result is usually lighter in the browser than the React equivalent.
I'd think twice in a few situations:
- Your team already lives in React. The ecosystem gap is real. Component libraries, auth SDKs and vendor widgets often ship React first, and some never ship a Svelte version. Rewriting those yourself eats the time you saved.
- The backend is the main product. If you have heavy background jobs, queues, and many API consumers, I'd keep that in a dedicated backend (Laravel, NestJS, whatever your team knows) and let SvelteKit be a thin frontend that calls it from server load functions.
- You're reading older tutorials. Svelte 5 replaced
$:andexport letwith runes ($state,$derived,$props). The code in this post is Svelte 4 style, which still compiles in Svelte 5, but new projects should use runes, and mixing both styles in one component isn't allowed. - You need lots of custom server middleware.
hooks.server.tshandles auth and logging fine, but if you're used to stacking dozens of Express middlewares, expect some friction.
What to Check Before Going to Production
- Secrets: anything imported from
$env/static/privateor$lib/servercan't leak to the client (the build fails if you try), so keep DB clients and keys there - Auth in hooks: resolve the session once in
src/hooks.server.tsand put the user onlocals, then checklocals.userin every action and endpoint that writes data - Adapter settings: with adapter-node, set
ORIGIN(orPROTOCOL_HEADERandHOST_HEADER) behind your proxy - Cache headers: never put
publiccache headers on pages that render user-specific data, or your CDN will serve one user's page to another - Tests: the wizard can set up Vitest and Playwright for you; at minimum, cover your form actions end to end
What I like most is how little of SvelteKit is new to learn. HTTP verbs, form posts, URL search params and cache headers all work the way the web already works, so most of what you pick up here carries over to any other stack.