Frontend

SvelteKit: Building Full-Stack Svelte Apps

Mayur Dabhi
Mayur Dabhi
June 5, 2026
14 min read

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.

Where the Speed Comes From

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:

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.

The Scaffolding Command Has Changed

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.

1

Scaffold a new project

Run the create command, choose a template, and select TypeScript support and any optional tooling (ESLint, Prettier, Playwright, Vitest).

Terminal
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
2

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.

Project Structure
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
3

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.

.env
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 File-Based Routing src/routes/ +layout.svelte wraps everything +page.svelte → / blog/+page.svelte → /blog api/posts/+server.ts → /api/posts [slug]/+page.svelte → /blog/:slug [slug]/+page.server.ts server-only load() (group) folders share layouts without affecting URLs

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:

src/routes/+layout.svelte
<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>
src/routes/+page.svelte
<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:

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.

src/routes/blog/[slug]/+page.server.ts
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
        }
    };
};
src/routes/blog/[slug]/+page.svelte
<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>
Destructuring data Breaks on Navigation

When 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.

src/routes/blog/+page.server.ts (with caching)
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 };
};
Avoid Browser APIs in Server Load Functions

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:

src/routes/api/posts/+server.ts
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>
SvelteKit Request Lifecycle (SSR) Browser GET /blog/post Route Match blog/[slug] load() +page.server.ts Database Prisma / SQL Render +page.svelte HTML streamed to browser → client-side hydration

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

Build commands
# 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:

What to Check Before Going to Production

  • Secrets: anything imported from $env/static/private or $lib/server can'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.ts and put the user on locals, then check locals.user in every action and endpoint that writes data
  • Adapter settings: with adapter-node, set ORIGIN (or PROTOCOL_HEADER and HOST_HEADER) behind your proxy
  • Cache headers: never put public cache 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.

SvelteKit Svelte Full-Stack JavaScript SSR Vite Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.