Guide

SvelteKit fundamentals explained

A regional publisher wants editors to draft articles, preview SEO metadata, and schedule posts — with public guide pages that load fast on mobile and rank in search. Hand-rolling Svelte with a separate Express API means two repos, duplicate validation, and hydration glue. SvelteKit is the official full-stack framework for Svelte: file-based routes, load functions that run on server or client, progressive- enhancement form actions, and adapters that compile the same app to Node, static hosting, Vercel, or Cloudflare Workers. Because Svelte compiles components to lean JavaScript, SvelteKit sites often ship smaller client bundles than comparable Next.js or Nuxt stacks for content-heavy products. This guide covers SvelteKit’s routing conventions, data loading, mutations, hooks, rendering modes, a Harbor Archive publishing portal worked example, a framework decision table, common pitfalls, and a production checklist alongside our Vite fundamentals guide and SSR vs CSR rendering guide.

What SvelteKit is (and how it extends Svelte)

Svelte is a UI compiler. SvelteKit is the application framework that answers routing, data fetching, deployment, and SEO:

  • Routing — directories under src/routes/ map to URLs; special files (+page.svelte, +layout.svelte) define UI shells.
  • Data loading+page.server.ts and +page.ts export load functions whose return values become page props.
  • Mutationsactions in +page.server.ts handle form POSTs with or without JavaScript.
  • Rendering — per-route export const prerender, ssr, and csr flags mix static, server-rendered, and client-only pages.
  • Build outputadapter-node, adapter-static, adapter-vercel, and others target your host without rewriting the app.

SvelteKit 2 (current major line) builds on Vite for dev HMR and production bundling. TypeScript is first-class; run npm create svelte@latest and pick the skeleton with TypeScript and ESLint from day one.

File-based routing conventions

SvelteKit uses route groups and special filenames instead of a central router config:

  • src/routes/+page.svelte — homepage at /
  • src/routes/guides/[slug]/+page.svelte — dynamic segment; access $page.params.slug
  • src/routes/(app)/dashboard/+layout.svelte — layout shared by nested routes; parentheses create a group without affecting the URL
  • src/routes/+layout.server.ts — root layout load (session user, feature flags)
  • src/routes/+error.svelte — error boundary UI

Layouts nest: child +page.svelte renders inside parent +layout.svelte via <slot /> (Svelte 4) or {@render children()} (Svelte 5). Use +layout.ts when layout data must run on both server and client (e.g. theme preference from a cookie readable in the browser).

Route parameters and matchers

Optional segments use double brackets: [[lang]]/about. Rest parameters capture tails: docs/[...path]. Custom matchers in src/params/slug.ts reject invalid slugs before load runs, saving database round-trips on garbage URLs.

Load functions: server, universal, and dependencies

The load function is SvelteKit’s data layer. Two file types matter:

  • +page.server.ts — runs only on the server. Safe for database queries, private API keys, and reading HttpOnly cookies. Return value is serialized to the client (watch payload size).
  • +page.ts — universal load. Runs on server during SSR and again on client navigations. Use for public fetches that must re-run when the user clicks an in-app link.
// src/routes/guides/[slug]/+page.server.ts
import { error } from '@sveltejs/kit'
import { db } from '$lib/server/db'

export async function load({ params }) {
  const post = await db.guide.findUnique({ where: { slug: params.slug } })
  if (!post) throw error(404, 'Guide not found')
  return { post }
}

In +page.svelte, access data with the data prop (SvelteKit 2 passes it explicitly). Use export let data or destructuring in <script>.

Invalidation and streaming

After a mutation, call invalidate('app:guides') or invalidateAll() from $app/navigation to re-run matching loads. For slow secondary data, return a Promise from load and use {#await} in the template — SvelteKit streams deferred chunks when supported.

Depends on parent data: export const load = async ({ parent }) => { const { user } = await parent(); ... } chains layout auth into page queries without duplicating session lookups.

Form actions and progressive enhancement

SvelteKit colocates mutations with pages via actions in +page.server.ts:

export const actions = {
  publish: async ({ request, locals }) => {
    const data = await request.formData()
    const title = String(data.get('title') ?? '').trim()
    if (!title) return fail(400, { title, missing: true })
    await db.guide.create({ data: { title, authorId: locals.user.id } })
    return { success: true }
  }
}

In the page, <form method="POST" action="?/publish"> works without JavaScript — critical for resilience and accessibility. With JS enabled, use use:enhance for optimistic UI and no full-page reload. The form store from $app/forms exposes form.message and validation errors returned via fail().

Prefer actions over client-side fetch for editor workflows: CSRF protection is built in, secrets stay server-side, and the same handler serves no-JS and enhanced clients. Reserve +server.ts route handlers for webhooks, JSON APIs consumed by third parties, or SSE streams.

Hooks, locals, and environment

src/hooks.server.ts runs on every request:

  • handle — authenticate sessions, set event.locals.user, add security headers, short-circuit unauthorized routes.
  • handleFetch — rewrite internal fetch during SSR (e.g. attach service token when the server calls your own API).
  • handleError — report errors to logging without leaking stack traces to users.

Use $env/static/private for server-only secrets and $env/static/public for values safe in the browser. Never import private env modules into +page.svelte — the build will fail if you try, but dynamic imports can still leak; keep DB clients in $lib/server/.

Rendering modes and adapters

Per-route exports control how HTML is produced:

Setting Effect Typical use
prerender = true Static HTML at build time Marketing pages, public guides with known slugs
ssr = true (default) Server renders HTML per request Personalized dashboards, auth-gated content
csr = false Disable client-side rendering for the page Rare; mostly embedded widgets or legacy constraints
trailingSlash in config Canonical URL shape Match CDN or static host expectations

Adapters in svelte.config.js emit deployable output:

  • @sveltejs/adapter-node — standalone Node server behind nginx
  • @sveltejs/adapter-static — fully prerendered sites on S3 or GitHub Pages
  • @sveltejs/adapter-vercel / adapter-cloudflare — serverless and edge functions

Hybrid apps prerender public content at build time while keeping /dashboard/** dynamic SSR. Use entries() in +page.server.ts to tell the prerender crawler which dynamic slugs exist.

Worked example: Harbor Archive publishing portal

Harbor Archive runs its editorial stack on SvelteKit with adapter-node and PostgreSQL via Drizzle. The architecture optimizes for fast public guide pages and a low-friction editor experience:

  • Route tree: routes/+page.svelte (marketing) → routes/guides/[slug]/+page.svelte (public article, prerendered) → routes/(editor)/drafts/+page.svelte (auth-gated list) → routes/(editor)/drafts/[id]/+page.svelte (WYSIWYG editor).
  • Public guide load: +page.server.ts fetches markdown body and SEO fields; entries() enumerates 800+ slugs at build for static generation.
  • Root layout auth: +layout.server.ts reads session cookie, sets locals.user; editor group layout redirects anonymous users to /login.
  • Autosave action: saveDraft action validates title length and slug uniqueness; returns fail(400, { errors }) inline without losing form state via use:enhance.
  • Publish action: transactional write to guides table + invalidates CDN tag; triggers invalidateAll() on success so the drafts list refreshes.
  • Preview mode: universal +page.ts on preview route fetches draft by token query param (client navigation after save).
  • Assets: uploaded images land in S3; hooks.server.ts signs short-lived URLs for editor thumbnails only.

Vitest tests load and actions by importing modules directly. Playwright covers publish flow end-to-end. Bundle analysis shows the public guide page ships ~42 KB gzip JS versus ~118 KB on their prior React SPA for the same template — compile-time Svelte and aggressive code-splitting per route explain the gap.

Framework decision table

Choose SvelteKit when… Prefer Next.js when… Prefer Nuxt when…
Bundle size and TTFB matter for content sites React hiring pool and RSC ecosystem are priorities Team is invested in Vue and Pinia patterns
You want colocated load + actions with minimal boilerplate Vercel-first Image Optimization and edge middleware are defaults Nitro modules and VueUse integrations are already standard
Progressive-enhancement forms are a first-class requirement Large App Router + Server Actions content exists for your stack Hybrid routeRules with mature CMS modules are needed
Design system favors HTML-centric templates over JSX npm package you need is React-only Existing Vue component library must be reused verbatim
Static prerender + small dynamic editor shell fits the product Complex client islands inside a server-rendered shell dominate Auto-imports and useFetch ergonomics beat Svelte syntax

Versus Remix: both emphasize Web Standards and forms. SvelteKit pairs with Svelte’s compiled output; Remix pairs with React loaders/actions. Versus plain Svelte + Vite SPA: add SvelteKit when you need SSR, file routing, or server mutations in one repo.

Common pitfalls

  • Fetching in onMount for SSR pages — users see empty content then a flash; move data into load.
  • Importing $lib/server/db into components — leaks server code to the client bundle; keep DB access in +page.server.ts and +server.ts only.
  • Returning huge objects from load — serialized payload bloats HTML; paginate lists and strip internal fields.
  • Skipping fail() on validation errors — throws 500; return fail(400, { fieldErrors }) so forms repopulate.
  • Prerendering auth-gated routes — build emits empty shells or leaks data; set prerender = false on private segments.
  • Wrong adapter for the hostadapter-static cannot serve dynamic POST actions without a separate backend.
  • Ignoring Svelte 5 migration — runes ($state, $derived) replace legacy export let and $:; plan upgrades before greenfield depends on deprecated patterns.

Production checklist

  • Scaffold with npm create svelte@latest; enable TypeScript, ESLint, and Prettier.
  • Organize $lib/server/ for DB, auth, and secrets; keep $lib/ isomorphic utilities separate.
  • Every public page gets data from load; mutations use actions with use:enhance.
  • hooks.server.ts sets session locals; editor routes check auth in layout server load.
  • Configure prerender + entries() for known slugs; leave dashboards on SSR.
  • Pick adapter matching deploy target; health check at src/routes/health/+server.ts returning 200.
  • Set <svelte:head> title and meta from load data; verify with view-source on prerendered pages.
  • Vitest for load/actions; Playwright smoke on login, save, and publish paths.
  • CDN caches immutable assets from /_app/immutable/; dynamic HTML bypasses cache.
  • Monitor server logs for handleError spikes after deploys.

Key takeaways

  • SvelteKit layers routing, data loading, and deployment on top of compiled Svelte components.
  • +page.server.ts load functions and actions keep secrets server-side and support no-JS forms.
  • Adapters target Node, static hosts, and serverless without rewriting application code.
  • Hybrid prerender + SSR fits content sites with authenticated editor shells.
  • Choose SvelteKit when lean bundles, progressive enhancement, and Svelte ergonomics align with the product.

Related reading