Modern Web · Sveltekit

SvelteKit 2 in Production

Running SvelteKit 2 in production: adapter choices, load function data flow, form actions, and the deployment patterns that hold up once real traffic hits the app.

John Kihiu12 min read

SvelteKit 2 tightened up a lot of the rough edges that made SvelteKit 1 feel like a framework still finding its shape — error handling got simpler, redirects and errors are now plain functions instead of thrown magic objects, and the adapter ecosystem matured to the point where deploying to any of the major platforms is a config file, not a workaround. None of that is exciting to write about, which is exactly why it's worth writing about: boring, predictable framework behavior is what you want once you're running the thing in production instead of writing a demo.

Picking an adapter

The adapter is the single decision that determines the shape of your production deployment, and it's worth making deliberately rather than defaulting to whatever the CLI scaffolds. adapter-node gives you a standalone Node server you run yourself — the right choice if you already have infrastructure (a VM, a container orchestrator, a reverse proxy) and want SvelteKit to be just another Node process behind it. adapter-vercel, adapter-netlify, and adapter-cloudflare map SvelteKit's routing onto each platform's serverless/edge model, trading some control for zero-ops deployment. The mistake I've seen teams make is picking a serverless adapter and then writing code that assumes a long-lived process — in-memory caches, global mutable state, background timers — none of which survive a cold start or a function that gets torn down after the response.

JS · svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

export default {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter({
      out: 'build',
      envPrefix: 'APP_'
    })
  }
};

Load functions and where data actually comes from

SvelteKit's load functions are the backbone of the framework, and the distinction that trips people up is server load (+page.server.js) versus universal load (+page.js). Server load runs only on the server and can safely touch a database connection, a secret API key, or anything that must never reach the client bundle. Universal load runs on the server during SSR and again in the browser during client-side navigation, so it can't hold secrets but it can call public APIs and reuse fetched data across navigations more cheaply. Putting a database query in a universal load function by mistake either crashes in the browser or, worse, ships your connection string to client JS — SvelteKit doesn't stop you from making that mistake, so the discipline has to come from knowing which file you're in.

Streaming with promises can surprise you

Returning a non-awaited promise from a server load function streams it to the page and resolves it client-side — useful for slow, non-critical data, but easy to do by accident if you forget an await. Check your load functions for stray unresolved promises; a slow one turns into a layout shift or a loading spinner nobody intended.

Form actions instead of client-side fetch calls

Form actions are the part of SvelteKit that feels genuinely different from the SPA-with-an-API-layer pattern most of us defaulted to for a decade. A `+page.server.js` can export named `actions`, and a plain HTML `<form method="POST">` posts to them without any client-side JavaScript required — progressive enhancement is the default, not an afterthought bolted on later. `use:enhance` upgrades the same form to submit via fetch once JS is available, giving you optimistic UI and no full-page reload, but the form still works if JS fails to load, which matters more than it sounds like on flaky mobile connections.

Caching headers actually matter here

Because SvelteKit renders on the server by default, response caching is a real production lever, not an afterthought. Setting `Cache-Control` headers in a load function (via `setHeaders`) lets you cache public, non-personalized pages at a CDN or edge layer, which is often a bigger performance win than any client-side optimization. The trap is applying the same caching to pages that read cookies or session state — a cached page that ignores per-user data will happily serve one user's content to another, which is a much worse bug than a slow page.

JS · +page.server.js
export async function load({ setHeaders, fetch }) {
  const res = await fetch('/api/articles');
  const articles = await res.json();

  setHeaders({
    'cache-control': 'public, max-age=60, s-maxage=300'
  });

  return { articles };
}

What actually breaks once real traffic hits it

In practice, the production issues I've hit with SvelteKit 2 haven't been framework bugs — they've been assumptions carried over from SPA development. Environment variables not prefixed correctly for the chosen adapter, `fetch` calls inside load functions that don't reuse SvelteKit's own fetch wrapper (and so lose credential forwarding during SSR), and hydration mismatches from server-rendered content that depends on `window` or `Date.now()` without guarding for the server/client boundary. None of these are exotic; they're the same class of bug every SSR framework produces, and the fix is the same discipline every time: know which environment a given piece of code runs in before you write it.

Wrapping up

SvelteKit 2 in production comes down to three decisions made deliberately instead of by default: which adapter matches your actual infrastructure, which load function type belongs where data sensitivity requires it, and which pages are safe to cache versus which depend on per-user state. Get those three right and the framework mostly gets out of your way — which, for a web framework, is the highest praise there is.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.