Skip to content

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

The default behavior stores only the user's selection (mode + family), not the full theme definition.

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.

core — default persistence with the plugin
ts
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.
Server-side rendering |Persistence adapters are null on the server — no localStorage access during SSR. The default initial theme resolves first, then the client-side adapter takes over.

2Storage keys

Customize the key under which the selection is stored to avoid collisions on shared origins.

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.

runtime — custom storage key
ts
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

Any object that implements ThemeSelectionPersistenceAdapter can be plugged in.

The ThemeSelectionPersistenceAdapter interface is four methods: get, set, remove, and subscribe. Implement them against any storage backend.

sessionStorage — tab-scoped, cleared on close
custom — sessionStorage adapter
ts
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}
Cookies — survives incognito, works server-side
custom — cookie adapter
ts
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}
URL hash — shareable, bookmarkable
custom — URL hash adapter
ts
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

Use HTTP cookies to persist selection across environments where localStorage is unavailable.

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.

next.js — cookie-based server-side persistence
tsx
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}
Zero-flash on return visits |The cookie is read server-side during SSR, so the page renders with the correct theme from the very first byte — no client-side flash.

5Disabling persistence

Pass null to the persistence option or omit the plugin entirely.

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.

core — disabling persistence
ts
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

Persistence and broadcast solve different problems — they are complementary, not competing.
Persistence
  • Same browser, across visits
  • Writes to storage on every change
  • Read once on init
  • Survives tab close and restart
  • Backed by localStorage, cookies, etc.
Sync (broadcast)
  • Same browser, same moment
  • No storage writes — in-memory messages
  • Real-time across open tabs
  • Lost on tab close
  • Backed by BroadcastChannel
persistence vs. broadcast
ts
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".
Use both together |Broadcast syncs open tabs instantly; persistence makes the choice stick when a tab closes and reopens. They don't conflict.
Persistence — Theme Kit