SvelteKit Locale Cookie Not Persisting
A SvelteKit locale cookie that “resets” on the next request is almost always a scope or timing bug: cookies.set was called without path: '/', or the cookie was written inside a load function where SvelteKit refuses to mutate response headers, so the Set-Cookie never reaches the browser. The symptom is consistent — you click a language switch, the UI flips to German, then the very next navigation or a hard refresh snaps back to English. The cookie either was never stored, was stored under a narrower path than the page you land on, or was stored but the server-side load that picks the locale never re-ran to read it. This page isolates each of those failure modes, shows the smallest reproduction, and gives the correct pattern: write the cookie from a form action or the handle hook with an explicit root path, then read it in handle (not in client document.cookie) so server-side rendering and the client agree on the very first paint.
path: '/' from a place that can set response headers, then read back in handle.This page assumes you already have the store-and-dictionary setup from SvelteKit Internationalization Basics; here we deal only with making the chosen locale stick.
Root Cause: Three Distinct Failure Modes
The “cookie won’t persist” complaint hides three separate bugs, and they need different fixes.
1. Missing path: '/'. SvelteKit’s cookies.set(name, value, opts) requires path — and if you pass a narrow path, the browser only sends the cookie back for that exact subtree. The default behaviour many developers expect (cookie valid site-wide) does not happen unless you write path: '/'. A cookie set while handling /settings with path: '/settings' is invisible when the user navigates to /dashboard, so the locale “resets” even though the cookie still exists. Per RFC 6265, a cookie’s Path attribute scopes which request paths include it; SvelteKit deliberately makes path mandatory so this scoping is never accidental.
2. Setting the cookie inside load. A load function — whether +page.ts, +page.server.ts, or +layout.server.ts — runs during data loading, where SvelteKit does not allow mutating the outgoing response headers in the general case. Calling cookies.set from a server load will throw (Cookies can only be set during \handle`, in a server load or action) or, in older versions, silently no-op. Cookies belong in a **form action**, a +server.tsrequest handler, or thehandle hook — places that own the response. A universal (+page.ts`) load cannot touch cookies at all because it may run in the browser.
3. Reading document.cookie on the client instead of the server. If the active locale is resolved by reading document.cookie in browser code, the server renders the default locale (it never looked at the cookie), the client then reads the cookie and re-renders in the correct locale — producing a flash of the wrong language and, with hydration-sensitive content, a hydration mismatch. The server must read the cookie in handle so the very first HTML byte is already in the right locale.
A fourth, subtler variant: the cookie is written correctly, but the server load that selects the locale never re-runs after the switch, so the page keeps rendering the stale value until a full reload. That is an invalidateAll problem, covered in the fix.
Minimal Reproducible Example
Here is the smallest setup that exhibits the reset. A language switcher posts to a server load that tries to write the cookie:
// src/routes/+layout.server.ts — BROKEN: cookie write in load
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ url, cookies }) => {
const next = url.searchParams.get('lang');
if (next) {
// Throws: "Cookies can only be set during `handle`,
// in a server load or action" with no path; and even with a
// path, a load is the wrong place for a switch side-effect.
cookies.set('locale', next); // ← no path, wrong location
}
const locale = cookies.get('locale') ?? 'en';
return { locale };
};
Even if you add a path to silence the throw, the switch is driven by a query string the load reads on every render, the Set-Cookie is unreliable, and nothing tells SvelteKit to re-run the load after navigation — so the change does not stick. Click to German, refresh, and you are back to English.
The Fix: Form Action Sets the Cookie With path: '/'
Move the write into a form action. Actions own the response, so cookies.set is allowed, and you can scope it to the whole site. The switcher becomes a tiny <form method="POST"> that works without JavaScript and progressively enhances.
// src/routes/+layout.server.ts — read only; never write here
import type { LayoutServerLoad } from './$types';
import { isLocale, DEFAULT_LOCALE } from '$lib/locales';
export const load: LayoutServerLoad = ({ locals }) => {
// locals.locale is populated by the handle hook (see below).
return { locale: locals.locale };
};
// src/routes/locale/+page.server.ts — the switch endpoint
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { isLocale, DEFAULT_LOCALE } from '$lib/locales';
export const actions: Actions = {
default: async ({ request, cookies, url }) => {
const data = await request.formData();
const next = String(data.get('locale'));
const locale = isLocale(next) ? next : DEFAULT_LOCALE;
cookies.set('locale', locale, {
path: '/', // ← site-wide: sent on every request, not just /locale
maxAge: 60 * 60 * 24 * 365, // persist a year, survives the session
httpOnly: true, // server reads it in handle; client never needs it
sameSite: 'lax', // sent on top-level navigations after the redirect
secure: true // required for SameSite over HTTPS
});
// Bounce back to where the user was; the redirected GET re-runs
// every server load, so the new locale renders immediately.
const back = url.searchParams.get('redirectTo') ?? '/';
throw redirect(303, back);
}
};
Read the Cookie in handle, Not on the Client
The server must know the locale before it renders a single byte. Read the cookie in the handle hook and stash it on event.locals, so every server load sees the same value and SSR output is already localized.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { isLocale, DEFAULT_LOCALE } from '$lib/locales';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.cookies.get('locale');
// Single source of truth for the request: cookie → validated → locals.
event.locals.locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE;
// Make the lang visible in the served HTML so the FIRST paint is correct.
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('%lang%', event.locals.locale)
});
};
// src/app.d.ts — type locals so TypeScript knows about it
declare global {
namespace App {
interface Locals {
locale: string;
}
}
}
export {};
Because handle runs on every request, the cookie is read before any load, the locale flows through locals, and there is no client-side document.cookie read to cause a flash. The same resolved locale can feed the fallback chain when a requested key is missing, and it slots in alongside the reroute-based prefix routing if you also carry the locale in the URL.
If You Switch Without a Navigation: invalidateAll
When the switch is a client-side fetch (an enhanced form or a programmatic toggle) and you do not redirect, the cookie is updated but the already-rendered server load data is stale — SvelteKit will not re-run load just because a cookie changed. Force it:
// after a programmatic cookie update via fetch('/locale', { method: 'POST', body })
import { invalidateAll } from '$app/navigation';
async function setLocale(locale: string) {
await fetch('/locale', { method: 'POST', body: new URLSearchParams({ locale }) });
// Re-run every load function so locals.locale is re-read from the new cookie.
await invalidateAll();
}
The redirect in the form action already triggers a fresh GET that re-runs all loads, so prefer the redirect path when you can; reach for invalidateAll only for the no-navigation case.
Verification
Assert the Set-Cookie header carries Path=/ and that a follow-up request renders the new locale. A direct request test:
# 1. POST the switch and inspect the Set-Cookie header.
$ curl -si -X POST http://localhost:5173/locale \
-d 'locale=de' | grep -i set-cookie
set-cookie: locale=de; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax; Secure
# 2. Send the cookie back and confirm SSR emits the German <html lang>.
$ curl -s --cookie 'locale=de' http://localhost:5173/ | grep -o '<html lang="[a-z]*"'
<html lang="de"
A unit test pins the handle resolution so a bad cookie can never leak through:
import { describe, it, expect } from 'vitest';
import { handle } from '../src/hooks.server';
function run(cookieVal: string | undefined) {
const event: any = {
cookies: { get: () => cookieVal },
locals: {}
};
// resolve is a no-op spy; we only assert locals.locale was set.
handle({ event, resolve: async () => new Response() } as any);
return event.locals.locale;
}
describe('handle locale resolution', () => {
it('uses a valid cookie', () => expect(run('de')).toBe('de'));
it('falls back for garbage', () => expect(run('xx')).toBe('en'));
it('falls back when absent', () => expect(run(undefined)).toBe('en'));
});
When to Escalate
This pattern covers the cookie-as-source-of-truth case. If your locale must also live in the URL (for crawlable, shareable localized links), the cookie becomes a secondary signal and you have to decide precedence — URL prefix usually wins over cookie. At that point the cookie fix here is necessary but not sufficient; pair it with the URL-prefix mechanics in the parent cluster, SvelteKit Internationalization Basics, and define the negotiation order explicitly using Locale Negotiation Strategies so the cookie and the URL never disagree. If the cookie persists locally but vanishes only in production behind a CDN, the next suspect is an edge cache stripping or ignoring Set-Cookie and serving a cached HTML body for all locales — a caching-key problem, not a SvelteKit one.
FAQ
Why does my locale cookie disappear on the next page?
Almost always because it was written without path: '/'. A cookie scoped to the path where you set it (say /settings) is not sent back when the browser requests a different path, so the server falls back to the default locale and the UI resets. Set the cookie with path: '/' so it applies to the whole site, and read it server-side in the handle hook on every request.
Can I call cookies.set inside a SvelteKit load function?
No, not reliably. A server load is not allowed to mutate the outgoing response in the general case, and a universal +page.ts load may run in the browser where it has no response at all. Write cookies from a form action, a +server.ts handler, or the handle hook — all of which own the response and can emit Set-Cookie.
Why does the locale change but only after a full refresh?
Because the cookie updated but no load re-ran to read the new value. A server-side redirect (303) after the switch triggers a fresh GET that re-runs every load; for a client-only switch with no navigation, call invalidateAll() after the fetch so SvelteKit re-reads locals.locale from the updated cookie.
Related
- SvelteKit Internationalization Basics — the store and dictionary setup this cookie selects between.
- SvelteKit Route Prefixing for Multiple Locales — carrying the locale in the URL when the cookie is only a secondary signal.
- Locale Negotiation Strategies — deciding precedence when cookie, URL, and Accept-Language disagree.
- Fallback Chain Configuration — what the resolved locale resolves to when a key is missing.