Feature flags in SvelteKit with server-load assignment
To add feature flags in SvelteKit, call difLoad(event) from @dif.sh/svelte/server inside src/routes/+layout.server.ts, then hand the result to initDif() in your root +layout.svelte. The server picks every variant before the page renders, and the client reuses that exact decision, so hydration never flips the UI.
The usual SvelteKit flag bug is the flash. Your server renders the control branch, the browser fetches flag state from a dashboard, and the button copy changes half a second after paint. Anyone measuring that experiment is now measuring a layout shift as well as a copy change. Server-load assignment removes the fetch: the variant is computed from a cookie and the request headers, on the server, before any HTML goes out.
The wiring is two files and one store, plus one route type where server assignment does not work.
Key Takeaways
difLoad(event)insrc/routes/+layout.server.tsmints adif_uidcookie, deriveslocaleanddevice_typefrom request headers, and assigns every registered experiment in one pass.- Assignment is the first four bytes of
SHA-256(salt || user_id)mod 10,000, read against cumulative weights. Server and client compute the same number, so there is nothing to reconcile on hydration.difLoadassigns without firing anything. Theexperiment()store records the exposure once, on the client, on first subscribe, so server rendering never inflates your counts.- On an ISR-cached route the server
loaddoes not re-run per visitor, soexperiment()falls back to client assignment and the control branch paints first.?_dif=checkout-cta-copy=variant_aforces a variant in any browser and fires no exposure.dif qa --forceprints the link.
Assign feature flags in a SvelteKit server load
Install the SDK and the Svelte adapter. The SDK is a peer dependency, so add both:
npm install @dif.sh/sdk @dif.sh/svelte
Then one server load at the root of your route tree:
// src/routes/+layout.server.ts
import "$lib/dif/generated/client"; // side effect: registers active experiments
import { difLoad } from "@dif.sh/svelte/server";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = (event) => ({ dif: difLoad(event) });
That import of the generated client is load-bearing. It populates the SDK registry so difLoad has something to enumerate. The helper lives at the @dif.sh/svelte/server subpath, which is pure TypeScript with no component code, so importing it from a server load function never drags client code into the server bundle.
difLoad does five things per request. It reads or mints the dif_uid cookie, derives audience attributes from headers, merges any attributes you pass over those, resolves QA forces, and assigns every registered experiment. It returns a serializable DifData blob and records nothing.
The default header mapping is deliberately small:
| Header | Attribute |
|---|---|
Accept-Language | locale, first value only |
User-Agent | device_type: mobile, tablet, or desktop |
Anything richer comes from your own app context, merged on top:
export const load: LayoutServerLoad = (event) => ({
dif: difLoad(event, {
attributes: {
plan: event.locals.user?.plan ?? null,
returning_visitor: Boolean(event.locals.user),
},
}),
});
Audiences declare which attribute names they target, and those declarations live in the repo. The values arrive at request time from event.locals, so no customer list gets committed to git to run one.
Initialize the SDK once in the root layout
The client half is one call in +layout.svelte. Publish the server’s decision to Svelte context, then init:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setContext } from "svelte";
import { initDif, DIF_CONTEXT_KEY } from "@dif.sh/svelte";
import { events } from "$lib/dif/generated/events";
let { data, children } = $props();
setContext(DIF_CONTEXT_KEY, data.dif);
initDif({ data: data.dif, events });
</script>
{@render children()}
initDif seeds userId from the difUid the server already used and reuses the server’s attribute bag, so an audience predicate cannot evaluate differently across the hydration boundary.
The events import carries your publishable key and cloud URL. Run dif connect --key dif_pk_live_... once and dif build bakes both into dif/generated/events.ts, so there are no PUBLIC_DIF_* environment variables to wire into your deploy. Skip it and assignment still works, because assignment is local. You get no analytics.
Read a flag in a component with the experiment store
experiment(id, branches) returns a Svelte readable of { value, variant }. Call it during component initialization, since it reads context:
<script lang="ts">
import { experiment, track } from "@dif.sh/svelte";
const checkoutCta = experiment("checkout-cta-copy", {
control: () => "Place order",
variant_a: () => "Get it today"
});
</script>
<button id="checkout-cta" onclick={() => track("checkout_conversion")}>
{$checkoutCta.value}
</button>
The branches are functions, so only the assigned one runs. The store reads the server’s decision out of context, which is why the first client render matches what the server already sent.
Exposure fires in the store’s subscribe callback, guarded on typeof window. It never fires on the server, and recordExposure dedupes per flag and user, so a re-subscribe costs nothing. If the assigned variant is not one of the branches you passed, which happens when a variant is renamed in Markdown before the call site catches up, the store renders the first declared branch and skips the exposure rather than attributing a control render to the new variant.
SvelteKit feature flags without the hydration flicker
Flicker comes from asking a network service what variant a user gets after the page has already painted. dif never asks. The variant is the first four bytes of SHA-256(salt || user_id) read as a big-endian integer, mod 10,000, matched against cumulative weights. Nothing is stored, so it is recomputed identically every time.
The dif_uid cookie is what makes that identical across the SSR boundary. It holds a random UUID, not a secret, and it is set with httpOnly: false on purpose so the browser can read the same id the server bucketed with. One year max age, path: /.
On plain HTTP in local dev, secure cookies get dropped, so pass difLoad(event, { secure: false }) there.
Because a seat assignment never moves, widening a rollout only repaints more buckets, and nobody has the new thing taken away mid-flight. Every failure mode resolves to the first branch and records nothing: no cookie, no audience match, an unknown flag id. A misconfigured flag shows the control copy instead of causing an incident, and an unassigned user is never counted.
The feature flag is a Markdown file, validated in CI
Nothing in that wiring touches a dashboard. The flag is a Markdown file in the repo, next to the component it changes. Here is the one the demo storefront ships:
---
id: checkout-cta-copy
status: active
owner: demo@dif.sh
surface: home
hypothesis: >
Urgency-led CTA copy ("Get it today") on the order summary will lift
checkout conversion over 14 days versus the neutral "Place order",
without regressing add-to-cart completion.
variants:
- id: control
weight: 50
summary: "Current copy: 'Place order'"
- id: variant_a
weight: 50
summary: "Urgency + delivery promise: 'Get it today'"
metrics:
primary: checkout_conversion
created: 2026-09-02
---
Ramping to 100% is editing weight: 50 to weight: 100 and merging. Rolling back is editing it to 0 and merging. The rollback is a commit, so why you rolled back sits in the git history instead of in someone’s memory of a 2am toggle.
dif validate checks that weights total 100, that the owner is a valid email, and that referenced surfaces and attributes exist. It scans your .svelte and .ts sources for call sites and warns on orphans, and it fails when two active experiments collide on one surface without an exclusion group. Run it in CI and a broken flag fails the PR like a broken build. dif build then compiles the active files into client.ts, audiences.ts, and a context.json your coding agent can read. The CLI reference covers each verb, and the .md format covers every frontmatter field.
The tradeoffs: ISR routes and rollback speed
Server-load assignment has one route type it does not fit. On an ISR-cached route the server load does not re-run per visitor, so the cached HTML is shared across everyone who hits it. There, experiment() detects no server assignment and buckets on the client from the dif_uid cookie: the control branch renders, then swaps once after hydration. That is the flicker you were avoiding, so do not server-assign on an ISR route unless you also vary the cache key on the headers difLoad reads.
The other cost is kill speed. Turning a flag off is a merge and a deploy, not a click. If your SvelteKit build takes ten minutes, your worst case kill is ten minutes. For a payment path that needs a sub-second kill switch, use something built for that on that one flag. For most flags, where the failure mode is that some users see the new thing, a merge and a deploy is fine.
QA a variant with a preview link
Forcing a variant needs no code and no devtools. Append ?_dif= to any URL:
# force one experiment for the tab session
https://staging.example.com/checkout?_dif=checkout-cta-copy=variant_a
# two at once
...?_dif=checkout-cta-copy=variant_a,home-hero=control
# clear the force
...?_dif=off
# print the exact link
dif qa --force checkout-cta-copy=variant_a --preview-url https://staging.example.com/checkout
difLoad honors the param server-side, so the forced variant is what gets server-rendered. A forced assignment fires no exposure, so design and product can walk the flow on production without polluting the result. A small badge shows which force is active with a one-click clear. Pass allowOverrides: false to difLoad and initDif to disable it per environment, or preview: false to keep the badge hidden.
Getting started
Install the CLI, scaffold the project, then add the two runtime packages:
npm install -g @dif.sh/cli
dif init
npm install @dif.sh/sdk @dif.sh/svelte
Feature flags in SvelteKit come down to two wiring points and one store. difLoad in +layout.server.ts decides every variant before the response goes out, initDif in +layout.svelte hands the client the same decision, and experiment() reads it wherever you need it. The flag itself stays in your repo as Markdown, reviewed in a pull request and validated in CI.
Run dif new to draft your first flag file. The Svelte and React adapter docs cover the rest of the API, and feature flags for React shows the same wiring with a provider and a hook.
For the workflow around the code, the feature flags page walks the lifecycle from draft to concluded decision.
FAQ
How do I add feature flags to a SvelteKit app? Install @dif.sh/sdk and @dif.sh/svelte, call difLoad(event) in src/routes/+layout.server.ts, and pass data.dif to initDif() in your root +layout.svelte. Then read a flag anywhere with the experiment() store. The flags live in your repo as Markdown files, so there is no dashboard to configure.
Do feature flags work with SvelteKit SSR? Yes, and server-side rendering is the default path. difLoad runs in the server load function and assigns every registered experiment before the page renders, so the HTML that ships already contains the right variant. Exposures are held back until the client subscribes.
How do I stop feature flags from flickering on hydration? Assign on the server and reuse that decision on the client. dif buckets by hashing the dif_uid cookie value, which both sides read, so the server and browser compute the same variant with no network call. The mismatch that causes flicker never occurs, unless the route is ISR-cached.
Does this work on Vercel with ISR? Partly. On an ISR-cached route the server load does not re-run per visitor, so experiment() falls back to assigning on the client and the control branch paints first. Either keep server assignment off ISR routes, or vary the cache key on the headers difLoad reads.
Do I need an account or an API key? No. Assignment is computed locally, so flags, rollouts, and A/B splits all run with nothing hosted. Run dif connect --key ... only when you want exposure and outcome events sent to dif Cloud for analysis.
How do I preview a variant before rolling it out? Open the page with ?_dif=<flag-id>=<variant-id>, or generate the link with dif qa --force. The force persists for the tab session, survives navigation, and fires no exposure, so QA traffic stays out of the numbers. ?_dif=off clears it.