Persistence
Remember the user's choice
Theme Kit remembers which theme the user picked so the next visit starts on the right one. Persistence is opt-in, pluggable, and designed to work alongside — not replace — cross-tab sync.
1How persistence works
When a persistence adapter is present, the runtime reads the saved state on initialization and writes every subsequent user choice. Only the selection metadata is stored — the actual theme tokens stay in your bundle and are resolved at runtime.
1import { createPersistencePlugin } from "@theme-kit/core";
2
3// The persistence plugin writes the full selection state
4// (mode + family) to localStorage under "theme-selection".
5const persistence = createPersistencePlugin();
6
7const runtime = createThemeRuntime({
8 themes,
9 defaultTheme: "light",
10 plugins: [persistence],
11});
12
13// On startup the plugin reads the saved state — if a returning
14// user picked "dark" + "corporate" last visit, the runtime
15// already resolves to that theme before the first paint.null on the server — no localStorage access during SSR. The default initial theme resolves first, then the client-side adapter takes over.2Storage keys
By default the plugin uses theme-selection as the storage key. If multiple Theme Kit apps run on the same origin, pass a unique key to each one.
1// Pass a custom key to avoid collisions with other
2// apps on the same origin.
3const runtime = createThemeRuntime({
4 themes,
5 plugins: [createPersistencePlugin({ key: "my-app-theme" })],
6});3Custom adapters
The ThemeSelectionPersistenceAdapter interface is four methods: get, set, remove, and subscribe. Implement them against any storage backend.
1import type { ThemeSelectionPersistenceAdapter } from "@theme-kit/core";
2
3function createSessionStoragePersistence(
4 key = "theme-selection",
5): ThemeSelectionPersistenceAdapter {
6 return {
7 get() {
8 const raw = sessionStorage.getItem(key);
9 if (!raw) return null;
10 try {
11 return JSON.parse(raw) as ThemeSelectionState;
12 } catch {
13 return null;
14 }
15 },
16
17 set(value) {
18 sessionStorage.setItem(key, JSON.stringify(value));
19 },
20
21 remove() {
22 sessionStorage.removeItem(key);
23 },
24
25 subscribe(listener) {
26 // sessionStorage doesn't fire cross-tab storage events,
27 // but we still implement the interface for consistency.
28 return () => {};
29 },
30 };
31}1import type { ThemeSelectionPersistenceAdapter } from "@theme-kit/core";
2
3function parseCookies(header: string): Record<string, string> {
4 return Object.fromEntries(
5 header.split(";").map((c) => {
6 const [k, ...v] = c.trim().split("=");
7 return [k, decodeURIComponent(v.join("="))];
8 }),
9 );
10}
11
12function createCookiePersistence(
13 name = "theme-selection",
14): ThemeSelectionPersistenceAdapter {
15 return {
16 get() {
17 const cookies = parseCookies(document.cookie);
18 const raw = cookies[name];
19 if (!raw) return null;
20 try {
21 return JSON.parse(raw) as ThemeSelectionState;
22 } catch {
23 return null;
24 }
25 },
26
27 set(value) {
28 document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))}; path=/; max-age=31536000; SameSite=Lax`;
29 },
30
31 remove() {
32 document.cookie = `${name}=; path=/; max-age=0`;
33 },
34
35 subscribe(listener) {
36 return () => {};
37 },
38 };
39}1import type { ThemeSelectionPersistenceAdapter } from "@theme-kit/core";
2
3// Persist the theme in the URL hash so links share the state.
4function createUrlHashPersistence(): ThemeSelectionPersistenceAdapter {
5 return {
6 get() {
7 const hash = window.location.hash.slice(1);
8 if (!hash) return null;
9 const params = new URLSearchParams(hash);
10 const mode = params.get("mode");
11 const family = params.get("family");
12 if (
13 (mode === "light" || mode === "dark" || mode === "system") &&
14 family
15 ) {
16 return { mode, family };
17 }
18 return null;
19 },
20
21 set(value) {
22 const params = new URLSearchParams({
23 mode: value.mode,
24 family: value.family,
25 });
26 window.location.hash = params.toString();
27 },
28
29 remove() {
30 history.replaceState(null, "", window.location.pathname);
31 },
32
33 subscribe(listener) {
34 const handler = () => listener(null); // re-read on popstate
35 window.addEventListener("popstate", handler);
36 return () => window.removeEventListener("popstate", handler);
37 },
38 };
39}4Server-side persistence
In Next.js and other server-rendered frameworks, the initial render has no access to localStorage. Writing the selection to a cookie lets the server read it, apply the correct theme on the first paint, and pass it to the client runtime.
1// app/providers.tsx — client component
2"use client";
3
4import { useEffect, useState } from "react";
5import { ThemeProvider } from "@theme-kit/react";
6import type { ThemeSelectionState } from "@theme-kit/core";
7
8export function Providers({ children }: { children: React.ReactNode }) {
9 const [selection, setSelection] = useState<ThemeSelectionState | null>(null);
10
11 useEffect(() => {
12 // Read the cookie on mount — no localStorage involved.
13 const cookie = document.cookie
14 .split("; ")
15 .find((c) => c.startsWith("theme-selection="));
16
17 if (cookie) {
18 try {
19 setSelection(JSON.parse(decodeURIComponent(cookie.split("=")[1])));
20 } catch {}
21 }
22 }, []);
23
24 return (
25 <ThemeProvider
26 defaultTheme="light"
27 initialMode={selection?.mode}
28 initialFamily={selection?.family}
29 >
30 {children}
31 </ThemeProvider>
32 );
33}
34
35// app/actions.ts — server action
36"use server";
37
38import { cookies } from "next/headers";
39
40export async function saveThemeSelection(state: ThemeSelectionState) {
41 const cookieStore = await cookies();
42 cookieStore.set("theme-selection", JSON.stringify(state), {
43 path: "/",
44 maxAge: 60 * 60 * 24 * 365,
45 sameSite: "lax",
46 });
47}5Disabling persistence
Some apps treat every visit as fresh — kiosk mode, demos, or situations where the default theme is always correct. Disable persistence by passing null to the runtime.
1const runtime = createThemeRuntime({
2 themes,
3 defaultTheme: "light",
4 // Pass null explicitly to disable persistence.
5 persistence: null,
6
7 // If you are using the plugin instead of the raw option,
8 // simply omit it or pass an empty array.
9 plugins: [],
10});
11
12// The runtime starts fresh every page load — no localStorage
13// reads, no writes on theme change.6Persistence vs. sync
- Same browser, across visits
- Writes to storage on every change
- Read once on init
- Survives tab close and restart
- Backed by localStorage, cookies, etc.
- Same browser, same moment
- No storage writes — in-memory messages
- Real-time across open tabs
- Lost on tab close
- Backed by BroadcastChannel
1import {
2 createThemeRuntime,
3 createThemeSelectionBroadcast,
4 createPersistencePlugin,
5} from "@theme-kit/core";
6
7const runtime = createThemeRuntime({
8 themes,
9 defaultTheme: "light",
10
11 // Persistence: survives page reloads, writes to storage,
12 // read once on init.
13 plugins: [createPersistencePlugin()],
14
15 // Broadcast: syncs across tabs in real-time, no storage
16 // writes — messages travel through BroadcastChannel.
17 broadcast: createThemeSelectionBroadcast(),
18});
19
20// Tab A picks "dark" + "ocean" at 2:00 PM.
21// Tab B (already open) receives the broadcast → switches instantly.
22//
23// User closes both tabs, comes back tomorrow.
24// Tab C opens → reads localStorage → lands on "dark" + "ocean".