We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide27 min read

SvelteKit, the fullstack application framework for Svelte

SvelteKit is the official application framework for Svelte. File based routing, form actions, remote functions, deployment adapters, and pitfalls.

SvelteKit, the fullstack application framework for Svelte

SvelteKit is the official framework for building Svelte applications: routing derived from directory structure, server side data loading, form handling without writing your own API, and deployment to a chosen platform through a swappable adapter. The current version is 2.70.2, and the Svelte it builds on is at 5.56.8. Both packages are MIT licensed.

What sets this apart from other frameworks

The fundamental difference sits one level down, in Svelte itself, and is worth naming because everything else follows from it.

Svelte is a compiler rather than a library shipped with your application. Component code turns at build time into ordinary JavaScript operating directly on the document, so no intermediary layer comparing element trees reaches the browser. The result is a smaller bundle and less startup work, though the gap is narrower today than older comparisons suggest, since the competition has trimmed its own overhead too.

The second thing is the approach to forms, where this framework genuinely stands out. Form actions let you handle a submission without a separate API route and without client side code. The form works even when JavaScript fails to load, because underneath it is an ordinary form submission that the client layer merely enhances. What other frameworks require you to build deliberately is the default here.

The third is adapters. The same application code deploys to a serverless platform, to your own Node.js server, or as a static site, by changing one configuration entry. It is not costless magic, since some capabilities depend on the target environment, but moving between providers is markedly cheaper here than in solutions tied to one platform.

Remote functions, the newest change

This is the freshest piece worth knowing, because it changes how the data layer is written and is simultaneously marked experimental.

Remote functions let you call server code directly from a component, with no API route to define and no request handling to write. You write a function, mark it as a query, and call it in a component, while the framework handles the request, typing, and caching.

Two details are worth knowing, since older tutorials carry stale information. The method that used to run a query was removed in release 2.61.0 and replaced by awaiting the result directly in every context. A variant maintaining a long lived subscription also arrived, useful for data that changes over time.

There is a caveat that has to be stated plainly, though. The mechanism requires enabling two experimental options at once: async support in the compiler and remote functions in the framework configuration. Those two are coupled, and attempting to use queries with async disabled will not work. On a production project, treat it like any experimental feature: it can be built on, but the interface may still change.

What is SvelteKit?

SvelteKit is the official fullstack framework for Svelte, designed for building modern web applications. It combines the elegance and simplicity of Svelte with powerful server-side capabilities - SSR, file-based routing, form actions, and API endpoints. All of this with minimal boilerplate and incredible performance thanks to its unique compiler-based approach.

Unlike other frameworks, Svelte compiles components into native JavaScript at build time, eliminating the need to include a runtime in the bundle. This means smaller files, faster loading, and better performance. SvelteKit builds on this architecture and adds routing, data loading, form handling, and deployment adapters.

Why SvelteKit?

Key advantages of SvelteKit

  1. No runtime - The compiler generates native JavaScript
  2. Fastest framework - Smaller bundles, faster hydration
  3. Simple mental model - Intuitive syntax with no boilerplate
  4. File-based routing - Folder structure = URL structure
  5. Form actions - Native forms with progressive enhancement
  6. Universal - SSR, SSG, SPA, all in one
  7. Svelte 5 Runes - Modern reactivity system
  8. Vite under the hood - Lightning-fast dev server

SvelteKit vs other frameworks

FeatureSvelteKitNext.jsNuxtRemix
RuntimeNoneReactVueReact
Bundle sizeSmallestMediumMediumSmall
Learning curveEasyMediumMediumMedium
Form handlingNativeClient-sideClient-sideNative
CompilerYesNoNoNo
Dev experienceExcellentGoodGoodGood
TypeScriptFullFullFullFull
Deploy adaptersManyVercel-firstManyMany

When to choose SvelteKit?

  • Smaller teams - simpler syntax, less code
  • Performance-critical - smallest bundles
  • Progressive enhancement - native forms
  • Fast development - HMR via Vite
  • New projects - modern architecture

Installation and configuration

Creating a new project

Code
Bash
npx sv create my-app

# The interactive wizard will ask about:
# - Template (skeleton, demo app, library)
# - TypeScript (yes/no)
# - Add-ons (Tailwind, ESLint, Prettier, Playwright, Vitest)

cd my-app
npm install
npm run dev

Alternative methods

Code
Bash
# With pnpm
pnpm dlx sv create my-app

# With yarn
yarn dlx sv create my-app

# With bunx
bunx sv create my-app

# The create-svelte package behind "pnpm create svelte"
# and "yarn create svelte" is deprecated.
# npm prints a notice pointing at sv instead.

Project structure

Code
TEXT
my-app/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app.html              # HTML template
β”‚   β”œβ”€β”€ app.css               # Global styles
β”‚   β”œβ”€β”€ app.d.ts              # TypeScript declarations
β”‚   β”œβ”€β”€ lib/                  # Shared code ($lib alias)
β”‚   β”‚   β”œβ”€β”€ components/       # Components
β”‚   β”‚   β”œβ”€β”€ server/           # Server-only code
β”‚   β”‚   └── utils/            # Helpers
β”‚   └── routes/               # File-based routing
β”‚       β”œβ”€β”€ +page.svelte      # Home page
β”‚       β”œβ”€β”€ +page.server.ts   # Server load
β”‚       β”œβ”€β”€ +layout.svelte    # Layout
β”‚       β”œβ”€β”€ +error.svelte     # Error page
β”‚       └── api/              # API endpoints
β”‚           └── +server.ts
β”œβ”€β”€ static/                   # Static assets
β”œβ”€β”€ svelte.config.js          # Svelte config
β”œβ”€β”€ vite.config.ts            # Vite config
β”œβ”€β”€ tsconfig.json
└── package.json

Configuring svelte.config.js

JSsvelte.config.js
JavaScript
// svelte.config.js
import adapter from '@sveltejs/adapter-auto'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),

  kit: {
    adapter: adapter(),

    alias: {
      $components: 'src/lib/components',
      $utils: 'src/lib/utils'
    },

    csp: {
      mode: 'auto',
      directives: {
        'script-src': ['self']
      }
    },

    prerender: {
      handleHttpError: 'warn'
    }
  }
}

export default config

File-based routing

Basic structure

SvelteKit uses the folder structure inside src/routes/ to define routing:

Code
TEXT
src/routes/
β”œβ”€β”€ +page.svelte              # /
β”œβ”€β”€ +layout.svelte            # Layout for all pages
β”œβ”€β”€ about/
β”‚   └── +page.svelte          # /about
β”œβ”€β”€ blog/
β”‚   β”œβ”€β”€ +page.svelte          # /blog
β”‚   β”œβ”€β”€ +page.server.ts       # Data loading
β”‚   └── [slug]/               # Dynamic route
β”‚       β”œβ”€β”€ +page.svelte      # /blog/:slug
β”‚       └── +page.server.ts
β”œβ”€β”€ products/
β”‚   β”œβ”€β”€ +page.svelte          # /products
β”‚   └── [...rest]/            # Catch-all route
β”‚       └── +page.svelte      # /products/*
└── (auth)/                   # Route group (does not affect URL)
    β”œβ”€β”€ login/
    β”‚   └── +page.svelte      # /login
    └── register/
        └── +page.svelte      # /register

Special files

FileDescription
+page.sveltePage component
+page.tsUniversal load function
+page.server.tsServer-only load function
+layout.svelteLayout wrapper
+layout.tsLayout load function
+layout.server.tsServer-only layout load
+server.tsAPI endpoint
+error.svelteError boundary

Dynamic routes

src/routes/blog/[slug]/+page.svelte
SVELTE
<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
  export let data
</script>

<article>
  <h1>{data.post.title}</h1>
  <div class="content">
    {@html data.post.content}
  </div>
  <p>Author: {data.post.author}</p>
</article>
TSsrc/routes/blog/[slug]/+page.server.ts
TypeScript
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUnique({
    where: { slug: params.slug }
  })

  if (!post) {
    throw error(404, {
      message: 'Post not found'
    })
  }

  return { post }
}

Rest parameters (catch-all)

TSsrc/routes/docs/[...path]/+page.server.ts
TypeScript
// src/routes/docs/[...path]/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ params }) => {
  // params.path = "getting-started/installation"
  // for URL: /docs/getting-started/installation

  const segments = params.path.split('/')
  const doc = await fetchDoc(segments)

  return { doc, breadcrumbs: segments }
}

Optional parameters

TSsrc/routes/[[lang]]/about/+page.server.ts
TypeScript
// src/routes/[[lang]]/about/+page.server.ts
// Matches /about and /pl/about and /en/about
export const load: PageServerLoad = async ({ params }) => {
  const lang = params.lang || 'pl'
  return { lang }
}

Route groups

Route groups (name) organize code without affecting the URL:

Code
TEXT
src/routes/
β”œβ”€β”€ (marketing)/            # Marketing group
β”‚   β”œβ”€β”€ +layout.svelte      # Layout for marketing
β”‚   β”œβ”€β”€ pricing/
β”‚   β”‚   └── +page.svelte    # /pricing
β”‚   └── features/
β”‚       └── +page.svelte    # /features
β”œβ”€β”€ (app)/                  # App group
β”‚   β”œβ”€β”€ +layout.svelte      # Layout for app
β”‚   β”œβ”€β”€ dashboard/
β”‚   β”‚   └── +page.svelte    # /dashboard
β”‚   └── settings/
β”‚       └── +page.svelte    # /settings

Data loading

Universal load (runs on server and client)

TSsrc/routes/blog/+page.ts
TypeScript
// src/routes/blog/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ fetch, params, url }) => {
  // Use fetch - SvelteKit deduplicates it
  const response = await fetch('/api/posts')
  const posts = await response.json()

  // Query parameters
  const page = url.searchParams.get('page') || '1'

  return {
    posts,
    page: parseInt(page)
  }
}

Server load (runs only on server)

TSsrc/routes/blog/+page.server.ts
TypeScript
// src/routes/blog/+page.server.ts
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/database'
import { error, redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, cookies, locals }) => {
  const session = cookies.get('session')

  if (!session) {
    throw redirect(303, '/login')
  }

  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' }
  })

  const user = locals.user

  return { posts, user }
}

Layout load

TSsrc/routes/+layout.server.ts
TypeScript
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async ({ cookies }) => {
  const theme = cookies.get('theme') || 'light'

  return { theme }
}
src/routes/+layout.svelte
SVELTE
<!-- src/routes/+layout.svelte -->
<script>
  export let data
</script>

<div class="app" data-theme={data.theme}>
  <nav>Navigation</nav>
  <slot />
  <footer>Footer</footer>
</div>

Parent data

TSsrc/routes/dashboard/+page.ts
TypeScript
// src/routes/dashboard/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ parent }) => {
  // Get data from the parent layout
  const { user } = await parent()

  const stats = await fetchUserStats(user.id)

  return { stats }
}

Invalidation and reloading

Code
SVELTE
<script>
  import { invalidate, invalidateAll } from '$app/navigation'

  async function refreshPosts() {
    // Refresh a specific endpoint
    await invalidate('/api/posts')

    // Or refresh everything
    await invalidateAll()
  }
</script>

<button on:click={refreshPosts}>
  Refresh posts
</button>

Form actions

SvelteKit offers native forms with progressive enhancement - they work without JavaScript!

Basic form actions

TSsrc/routes/contact/+page.server.ts
TypeScript
// src/routes/contact/+page.server.ts
import type { Actions } from './$types'
import { fail } from '@sveltejs/kit'

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData()
    const email = data.get('email')
    const message = data.get('message')

    if (!email || !message) {
      return fail(400, {
        error: 'All fields are required',
        email,
        message
      })
    }

    await db.contact.create({
      data: { email, message }
    })

    return { success: true }
  }
}
src/routes/contact/+page.svelte
SVELTE
<!-- src/routes/contact/+page.svelte -->
<script>
  export let form
</script>

{#if form?.success}
  <p class="success">Message sent!</p>
{/if}

{#if form?.error}
  <p class="error">{form.error}</p>
{/if}

<form method="POST">
  <label>
    Email:
    <input
      type="email"
      name="email"
      value={form?.email ?? ''}
      required
    />
  </label>

  <label>
    Message:
    <textarea
      name="message"
      required
    >{form?.message ?? ''}</textarea>
  </label>

  <button type="submit">Send</button>
</form>

Named actions

TSsrc/routes/posts/+page.server.ts
TypeScript
// src/routes/posts/+page.server.ts
import type { Actions } from './$types'

export const actions: Actions = {
  create: async ({ request }) => {
    const data = await request.formData()
    const title = data.get('title')

    await db.post.create({ data: { title } })

    return { created: true }
  },

  delete: async ({ request }) => {
    const data = await request.formData()
    const id = data.get('id')

    await db.post.delete({ where: { id } })

    return { deleted: true }
  },

  update: async ({ request }) => {
    const data = await request.formData()
    const id = data.get('id')
    const title = data.get('title')

    await db.post.update({
      where: { id },
      data: { title }
    })

    return { updated: true }
  }
}
src/routes/posts/+page.svelte
SVELTE
<!-- src/routes/posts/+page.svelte -->
<script>
  export let data
</script>

<!-- Create form - named action -->
<form method="POST" action="?/create">
  <input name="title" placeholder="Post title" required />
  <button>Add post</button>
</form>

<!-- Post list -->
{#each data.posts as post}
  <article>
    <h2>{post.title}</h2>

    <!-- Delete form -->
    <form method="POST" action="?/delete">
      <input type="hidden" name="id" value={post.id} />
      <button>Delete</button>
    </form>

    <!-- Update form -->
    <form method="POST" action="?/update">
      <input type="hidden" name="id" value={post.id} />
      <input name="title" value={post.title} />
      <button>Update</button>
    </form>
  </article>
{/each}

Progressive enhancement

Code
SVELTE
<script>
  import { enhance } from '$app/forms'

  let loading = false
</script>

<form
  method="POST"
  action="?/create"
  use:enhance={() => {
    loading = true

    return async ({ result, update }) => {
      loading = false

      if (result.type === 'success') {
        await update()
      }
    }
  }}
>
  <input name="title" disabled={loading} />
  <button disabled={loading}>
    {loading ? 'Adding...' : 'Add'}
  </button>
</form>

API endpoints

Basic endpoints

TSsrc/routes/api/users/+server.ts
TypeScript
// src/routes/api/users/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async ({ url }) => {
  const limit = parseInt(url.searchParams.get('limit') || '10')

  const users = await db.user.findMany({ take: limit })

  return json(users)
}

export const POST: RequestHandler = async ({ request }) => {
  const data = await request.json()

  if (!data.email || !data.name) {
    throw error(400, 'Email and name are required')
  }

  const user = await db.user.create({ data })

  return json(user, { status: 201 })
}

Dynamic API routes

TSsrc/routes/api/users/[id]/+server.ts
TypeScript
// src/routes/api/users/[id]/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async ({ params }) => {
  const user = await db.user.findUnique({
    where: { id: params.id }
  })

  if (!user) {
    throw error(404, 'User not found')
  }

  return json(user)
}

export const PUT: RequestHandler = async ({ params, request }) => {
  const data = await request.json()

  const user = await db.user.update({
    where: { id: params.id },
    data
  })

  return json(user)
}

export const DELETE: RequestHandler = async ({ params }) => {
  await db.user.delete({
    where: { id: params.id }
  })

  return new Response(null, { status: 204 })
}

Streaming responses

TSsrc/routes/api/stream/+server.ts
TypeScript
// src/routes/api/stream/+server.ts
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async () => {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        await new Promise(r => setTimeout(r, 500))
        controller.enqueue(`data: ${JSON.stringify({ count: i })}\n\n`)
      }
      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  })
}

Files and upload

TSsrc/routes/api/upload/+server.ts
TypeScript
// src/routes/api/upload/+server.ts
import { json, error } from '@sveltejs/kit'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import type { RequestHandler } from './$types'

export const POST: RequestHandler = async ({ request }) => {
  const formData = await request.formData()
  const file = formData.get('file') as File

  if (!file) {
    throw error(400, 'No file provided')
  }

  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    throw error(400, 'File type not allowed')
  }

  const buffer = Buffer.from(await file.arrayBuffer())
  const filename = `${Date.now()}-${file.name}`
  const path = join('static', 'uploads', filename)

  await writeFile(path, buffer)

  return json({ url: `/uploads/${filename}` })
}

Svelte 5 Runes

Svelte 5 introduces Runes - a new reactivity system:

$state - reactive state

Code
SVELTE
<script>
  let count = $state(0)
  let user = $state({ name: 'Jan', age: 25 })

  function increment() {
    count++
  }

  function updateUser() {
    user.age++
  }
</script>

<p>Count: {count}</p>
<p>User: {user.name}, {user.age} years old</p>

<button onclick={increment}>+1</button>
<button onclick={updateUser}>Birthday</button>

$derived - computed values

Code
SVELTE
<script>
  let count = $state(0)

  let double = $derived(count * 2)
  let isEven = $derived(count % 2 === 0)

  let summary = $derived.by(() => {
    if (count === 0) return 'Zero'
    if (count < 10) return 'Few'
    return 'Many'
  })
</script>

<p>Count: {count}</p>
<p>Double: {double}</p>
<p>Even: {isEven ? 'Yes' : 'No'}</p>
<p>Summary: {summary}</p>

<button onclick={() => count++}>+1</button>

$effect - side effects

Code
SVELTE
<script>
  let count = $state(0)
  let savedCount = $state(0)

  $effect(() => {
    console.log(`Count changed to ${count}`)

    return () => {
      console.log('Cleanup')
    }
  })

  $effect(() => {
    if (count > 10) {
      savedCount = count
    }
  })

  $effect.pre(() => {
    // Runs before DOM update
  })
</script>

$props - component props

Child.svelte
SVELTE
<!-- Child.svelte -->
<script>
  let {
    name,
    age = 18,
    onUpdate,
    children
  } = $props()
</script>

<div>
  <h2>{name}, {age} years old</h2>
  <button onclick={() => onUpdate?.(age + 1)}>
    Birthday
  </button>
  {@render children?.()}
</div>
Parent.svelte
SVELTE
<!-- Parent.svelte -->
<script>
  import Child from './Child.svelte'

  let age = $state(25)
</script>

<Child
  name="Anna"
  {age}
  onUpdate={(newAge) => age = newAge}
>
  <p>This is content passed to the child</p>
</Child>

$bindable - two-way binding

Input.svelte
SVELTE
<!-- Input.svelte -->
<script>
  let { value = $bindable() } = $props()
</script>

<input bind:value />
Parent.svelte
SVELTE
<!-- Parent.svelte -->
<script>
  import Input from './Input.svelte'

  let text = $state('')
</script>

<Input bind:value={text} />
<p>Typed text: {text}</p>

Hooks and middleware

Server hooks

TSsrc/hooks.server.ts
TypeScript
// src/hooks.server.ts
import type { Handle, HandleFetch, HandleServerError } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
  const session = event.cookies.get('session')

  if (session) {
    const user = await getUserFromSession(session)
    event.locals.user = user
  }

  const response = await resolve(event, {
    transformPageChunk: ({ html }) => html.replace(
      '%theme%',
      event.cookies.get('theme') || 'light'
    )
  })

  response.headers.set('X-Custom-Header', 'value')

  return response
}

export const handleFetch: HandleFetch = async ({ request, fetch }) => {
  if (request.url.startsWith('https://api.internal.com')) {
    request.headers.set('Authorization', `Bearer ${API_KEY}`)
  }

  return fetch(request)
}

export const handleServerError: HandleServerError = async ({ error, event }) => {
  console.error('Server error:', error, 'URL:', event.url)

  await logError(error)

  return {
    message: 'A server error occurred'
  }
}

Client hooks

TSsrc/hooks.client.ts
TypeScript
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit'

export const handleClientError: HandleClientError = async ({ error, message }) => {
  console.error('Client error:', error)

  await trackError(error)

  return {
    message: 'Something went wrong'
  }
}

Auth middleware pattern

TSsrc/hooks.server.ts
TypeScript
// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit'

const publicRoutes = ['/', '/login', '/register', '/about']

export const handle: Handle = async ({ event, resolve }) => {
  const session = event.cookies.get('session')

  if (session) {
    try {
      const user = await verifySession(session)
      event.locals.user = user
    } catch {
      event.cookies.delete('session', { path: '/' })
    }
  }

  const isPublic = publicRoutes.some(route =>
    event.url.pathname === route ||
    event.url.pathname.startsWith('/api/public')
  )

  if (!isPublic && !event.locals.user) {
    throw redirect(303, '/login')
  }

  return resolve(event)
}

Prerendering and SSG

Prerendering configuration

TSsrc/routes/blog/+page.ts
TypeScript
// src/routes/blog/+page.ts
export const prerender = true

// src/routes/admin/+page.ts
export const prerender = false

Dynamic prerendering

TSsrc/routes/blog/[slug]/+page.server.ts
TypeScript
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad, EntryGenerator } from './$types'

export const entries: EntryGenerator = async () => {
  const posts = await db.post.findMany({
    select: { slug: true }
  })

  return posts.map(post => ({ slug: post.slug }))
}

export const prerender = true

export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUnique({
    where: { slug: params.slug }
  })

  return { post }
}

SSG + ISR pattern

JSsvelte.config.js
JavaScript
// svelte.config.js
const config = {
  kit: {
    adapter: adapter(),
    prerender: {
      entries: ['*'],

      handleHttpError: ({ path, referrer, message }) => {
        if (path.startsWith('/api/')) {
          return
        }
        throw new Error(message)
      }
    }
  }
}

Deployment adapters

Adapter Auto (auto-detection)

Code
Bash
npm install @sveltejs/adapter-auto
JSsvelte.config.js
JavaScript
// svelte.config.js
import adapter from '@sveltejs/adapter-auto'

const config = {
  kit: {
    adapter: adapter()
  }
}

Adapter Node.js

Code
Bash
npm install @sveltejs/adapter-node
Code
JavaScript
import adapter from '@sveltejs/adapter-node'

const config = {
  kit: {
    adapter: adapter({
      out: 'build',
      precompress: true,
      envPrefix: 'MY_APP_'
    })
  }
}

Adapter Static (SSG)

Code
Bash
npm install @sveltejs/adapter-static
Code
JavaScript
import adapter from '@sveltejs/adapter-static'

const config = {
  kit: {
    adapter: adapter({
      pages: 'build',
      assets: 'build',
      fallback: '404.html',
      precompress: false
    })
  }
}

Adapter Vercel

Code
Bash
npm install @sveltejs/adapter-vercel
Code
JavaScript
import adapter from '@sveltejs/adapter-vercel'

const config = {
  kit: {
    adapter: adapter({
      runtime: 'nodejs22.x',  // or 'nodejs20.x', 'nodejs24.x', 'bun1.x'
      regions: ['fra1'],
      split: true,
      // runtime: 'edge' still works, but its configuration variant is
      // marked deprecated in the adapter, and ISR does not support it
    })
  }
}

Adapter Cloudflare

Code
Bash
npm install @sveltejs/adapter-cloudflare
Code
JavaScript
import adapter from '@sveltejs/adapter-cloudflare'

const config = {
  kit: {
    adapter: adapter({
      routes: {
        include: ['/*'],
        exclude: ['<all>']
      }
    })
  }
}

Integrations

Tailwind CSS

Code
Bash
npx sv add tailwindcss
+page.svelte
SVELTE
<!-- +page.svelte -->
<script>
  let count = $state(0)
</script>

<div class="min-h-screen bg-gray-100 flex items-center justify-center">
  <div class="bg-white p-8 rounded-lg shadow-lg">
    <h1 class="text-2xl font-bold text-gray-800 mb-4">
      Counter: {count}
    </h1>
    <button
      onclick={() => count++}
      class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600
             transition-colors"
    >
      Increment
    </button>
  </div>
</div>

Prisma ORM

Code
Bash
npm install prisma @prisma/client
npx prisma init
TSsrc/lib/server/database.ts
TypeScript
// src/lib/server/database.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const db = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = db
}

Auth.js (NextAuth for SvelteKit)

Code
Bash
npm install @auth/sveltekit @auth/core
TSsrc/hooks.server.ts
TypeScript
// src/hooks.server.ts
import { SvelteKitAuth } from '@auth/sveltekit'
import GitHub from '@auth/sveltekit/providers/github'

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET
    })
  ]
})
src/routes/+page.svelte
SVELTE
<!-- src/routes/+page.svelte -->
<script>
  // $app/stores works, but $app/state replaced it from SvelteKit 2.12
  import { page } from '$app/state'
  import { signIn, signOut } from '@auth/sveltekit/client'
</script>

{#if page.data.session}
  <p>Logged in as {page.data.session.user?.email}</p>
  <button onclick={() => signOut()}>Sign out</button>
{:else}
  <button onclick={() => signIn('github')}>
    Sign in with GitHub
  </button>
{/if}

Superforms

Code
Bash
npm install sveltekit-superforms zod
TSsrc/routes/contact/+page.server.ts
TypeScript
// src/routes/contact/+page.server.ts
import { superValidate, message } from 'sveltekit-superforms'
// zod4 for Zod 4, which is what installs by default; the zod adapter targets Zod 3
import { zod4 } from 'sveltekit-superforms/adapters'
import { z } from 'zod'

const schema = z.object({
  email: z.email('Invalid email'),
  message: z.string().min(10, 'Minimum 10 characters')
})

export const load = async () => {
  const form = await superValidate(zod4(schema))
  return { form }
}

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod4(schema))

    if (!form.valid) {
      return { form }
    }

    // Send email...

    return message(form, 'Sent!')
  }
}
src/routes/contact/+page.svelte
SVELTE
<!-- src/routes/contact/+page.svelte -->
<script>
  import { superForm } from 'sveltekit-superforms'

  export let data

  const { form, errors, message, enhance } = superForm(data.form)
</script>

{#if $message}
  <p class="success">{$message}</p>
{/if}

<form method="POST" use:enhance>
  <label>
    Email:
    <input type="email" name="email" bind:value={$form.email} />
    {#if $errors.email}
      <span class="error">{$errors.email}</span>
    {/if}
  </label>

  <label>
    Message:
    <textarea name="message" bind:value={$form.message}></textarea>
    {#if $errors.message}
      <span class="error">{$errors.message}</span>
    {/if}
  </label>

  <button type="submit">Send</button>
</form>

Stores and state management

Svelte stores

TSsrc/lib/stores/cart.ts
TypeScript
// src/lib/stores/cart.ts
import { writable, derived } from 'svelte/store'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

function createCartStore() {
  const { subscribe, set, update } = writable<CartItem[]>([])

  return {
    subscribe,

    addItem: (item: Omit<CartItem, 'quantity'>) => {
      update(items => {
        const existing = items.find(i => i.id === item.id)
        if (existing) {
          existing.quantity++
          return [...items]
        }
        return [...items, { ...item, quantity: 1 }]
      })
    },

    removeItem: (id: string) => {
      update(items => items.filter(i => i.id !== id))
    },

    clear: () => set([])
  }
}

export const cart = createCartStore()

export const cartTotal = derived(cart, $cart =>
  $cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
Code
SVELTE
<script>
  import { cart, cartTotal } from '$lib/stores/cart'
</script>

<p>Total: {$cartTotal} PLN</p>

{#each $cart as item}
  <div>
    {item.name} x{item.quantity}
    <button onclick={() => cart.removeItem(item.id)}>
      Remove
    </button>
  </div>
{/each}

Context API

src/routes/+layout.svelte
SVELTE
<!-- src/routes/+layout.svelte -->
<script>
  import { setContext } from 'svelte'

  const theme = $state({ mode: 'light', accent: 'blue' })

  setContext('theme', {
    get current() { return theme },
    toggle: () => theme.mode = theme.mode === 'light' ? 'dark' : 'light'
  })
</script>

<slot />
Code
SVELTE
<!-- Any child component -->
<script>
  import { getContext } from 'svelte'

  const { current, toggle } = getContext('theme')
</script>

<button onclick={toggle}>
  Theme: {current.mode}
</button>

Testing

Vitest for unit tests

Code
Bash
npx sv add vitest
TSsrc/lib/utils.test.ts
TypeScript
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, calculateDiscount } from './utils'

describe('formatPrice', () => {
  it('formats price with currency', () => {
    expect(formatPrice(1234.56)).toBe('1 234,56 PLN')
  })

  it('handles zero', () => {
    expect(formatPrice(0)).toBe('0,00 PLN')
  })
})

describe('calculateDiscount', () => {
  it('calculates percentage discount', () => {
    expect(calculateDiscount(100, 20)).toBe(80)
  })
})

Playwright for E2E

The add-on configures Playwright, which runs the same test against the Chromium, Firefox, and WebKit engines and waits for an element to be ready before clicking instead of relying on artificial delays. The engines themselves come from a separate npx playwright install command, so the first CI run takes longer than the ones after it.

Code
Bash
npx sv add playwright
TStests/home.test.ts
TypeScript
// tests/home.test.ts
import { expect, test } from '@playwright/test'

test('homepage has correct title', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/Home/)
})

test('can navigate to about page', async ({ page }) => {
  await page.goto('/')
  await page.click('text=About us')
  await expect(page.url()).toContain('/about')
})

test('contact form submits successfully', async ({ page }) => {
  await page.goto('/contact')

  await page.fill('input[name="email"]', 'test@example.com')
  await page.fill('textarea[name="message"]', 'Test message')
  await page.click('button[type="submit"]')

  await expect(page.locator('.success')).toBeVisible()
})

Testing Library

Code
Bash
npm install @testing-library/svelte
TSsrc/lib/components/Counter.test.ts
TypeScript
// src/lib/components/Counter.test.ts
import { render, fireEvent } from '@testing-library/svelte'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.svelte'

describe('Counter', () => {
  it('renders initial count', () => {
    const { getByText } = render(Counter, { props: { initial: 5 } })
    expect(getByText('Count: 5')).toBeInTheDocument()
  })

  it('increments on click', async () => {
    const { getByText, getByRole } = render(Counter)

    await fireEvent.click(getByRole('button', { name: '+1' }))

    expect(getByText('Count: 1')).toBeInTheDocument()
  })
})

Performance optimization

Lazy loading

Code
SVELTE
<script>
  import { onMount } from 'svelte'

  let HeavyComponent = $state()

  onMount(async () => {
    const module = await import('./HeavyComponent.svelte')
    HeavyComponent = module.default
  })
</script>

{#if HeavyComponent}
  <svelte:component this={HeavyComponent} />
{:else}
  <p>Loading...</p>
{/if}

Streaming

TSsrc/routes/dashboard/+page.server.ts
TypeScript
// src/routes/dashboard/+page.server.ts
export const load = async () => {
  return {
    // Immediate
    user: await getUser(),

    // Streamed (does not block rendering)
    stats: getStats(),
    notifications: getNotifications()
  }
}
Code
SVELTE
<script>
  export let data
</script>

<h1>Welcome, {data.user.name}</h1>

{#await data.stats}
  <p>Loading stats...</p>
{:then stats}
  <Stats {stats} />
{/await}

{#await data.notifications}
  <p>Loading notifications...</p>
{:then notifications}
  <Notifications {notifications} />
{/await}

Service worker

TSsrc/service-worker.ts
TypeScript
// src/service-worker.ts
import { build, files, version } from '$service-worker'

const CACHE = `cache-${version}`

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE).then(cache => cache.addAll([...build, ...files]))
  )
})

self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') return

  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached || fetch(event.request)
    })
  )
})

FAQ - frequently asked questions

Is SvelteKit suitable for large projects?

Yes! SvelteKit scales excellently thanks to its modular architecture and small bundles. It is used in production by companies like The New York Times, Spotify, and Apple.

What is the difference between Svelte and SvelteKit?

Svelte is a component framework (like React/Vue), while SvelteKit is a fullstack meta-framework built on top of Svelte (like Next.js for React). SvelteKit adds routing, SSR, form actions, and API endpoints.

Can I use React components in SvelteKit?

Not directly - Svelte and React have different component models. However, you can integrate React widgets through portals or iframes.

How does reactivity work in Svelte 5?

Svelte 5 introduces Runes - $state, $derived, $effect - which are more explicit and predictable than the previous system based on $: and let. Runes work similarly to React hooks, but without the rules of hooks.

Does SvelteKit support ISR (Incremental Static Regeneration)?

SvelteKit does not have built-in ISR like Next.js, but you can achieve a similar effect through the Vercel adapter with the isr option or through revalidation on Cloudflare.

How to secure API endpoints?

Use hooks.server.ts for auth middleware, check event.locals.user in endpoints, and use form actions with CSRF protection (built into SvelteKit).

Does SvelteKit support Edge Functions?

Yes, though the picture has shifted. Cloudflare runs the application at the edge by definition, with no separate switch for it. In the Vercel adapter runtime: 'edge' still works, but the configuration variant it belongs to is marked deprecated, the supported runtime values today are nodejs20.x, nodejs22.x, nodejs24.x, and bun1.x, and the retired nodejs18.x fails the build. ISR works only in the serverless variant, so pairing it with the edge is not an option.

Is it worth starting a new project on remote functions?

It depends on your tolerance for change. The mechanism is marked experimental, requires enabling two options at once, and its interface changed within the current year. For a prototype or an internal project yes; for a product you do not want to rewrite in six months, load functions and form actions are safer.

Data loading, where mistakes cluster

The distinction between the two kinds of load function is what most newcomers trip over, and the consequences can be serious.

A universal function runs both on the server and in the browser. A server function runs on the server alone. The filename differs by one letter, and the difference in effect is fundamental: code placed in a universal function ends up in the bundle sent to users, along with everything it reaches for.

The practical rule: anything touching a database, keys, or environment variables belongs in the server variant. The framework does warn when a server module is imported into client code, but it will not save you from writing a key directly somewhere it should never appear.

The second trap concerns what such a function returns. The result has to be serialisable and restorable on the other side, so objects with methods, class instances, and references to browser elements will not survive. Return plain structures and assemble what you need inside the component.

The third is dependencies between functions. A result from a higher level flows into lower ones, so data shared across a whole section is worth fetching once at the top rather than repeating in every subpage. The reverse mistake happens too: putting a query needed on one subpage into a parent layout makes it run on every visit anywhere beneath it.

The fourth is invalidation. After an action that changes data, the framework reloads the relevant functions, but only those that declared a dependency. If a list does not refresh after saving a form, nine times out of ten that declaration is missing.

The fifth, and the most insidious, concerns streaming. A load function can return a promise instead of a ready value, so the page renders immediately while slower data arrives later. Wrapping everything that way is tempting, but then every section of the page appears separately and the interface jumps. Stream what is genuinely slow and secondary, a recommendations list beside the main content for instance, and fetch anything needed to make sense of the page normally.

SvelteKit against the alternatives

FeatureSvelteKitNext.jsNuxtAstro
Underlying layerSvelte, a compilerReactVueany, islands
Shipped code sizesmallestmediummediumsmallest for content sites
Forms without an APIbuilt inserver actionspartiallimited
Swapping deployment platformadapter, one lineprovider dependentadapteradapter
Ecosystem sizesmalllargestmediummedium
People available to hirelimitedhighmediumlimited

The choice is usually settled outside the feature list. If you are building an application where forms and data work form the core, and a small team decides its own technology, this framework offers the most pleasant work in the field. If the project has to live for years inside a larger organisation and pass through many hands, ecosystem and people availability weigh more than elegance, and the more popular framework wins despite worse numbers.

Documentation lives on the Svelte site, and the source in the project repository.