Persistence
Remember the user's choice
Themvora 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.
import { createPersistencePlugin } from "@themvora/core";
// The persistence plugin writes the full selection state
// (mode + family) to localStorage under "theme-selection".
const persistence = createPersistencePlugin();
const runtime = createThemeRuntime({
defaultTheme: "light",
plugins: [persistence],
});
// On startup the plugin reads the saved state — if a returning
// user picked "dark" + "corporate" last visit, the runtime
// 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 Themvora apps run on the same origin, pass a unique key to each one.
// Pass a custom key to avoid collisions with other
// apps on the same origin.
const runtime = createThemeRuntime({
plugins: [createPersistencePlugin({ key: "my-app-theme" })],
});3Custom adapters
The ThemeSelectionPersistenceAdapter interface is four methods: get, set, remove, and subscribe. Implement them against any storage backend.
import type { ThemeSelectionPersistenceAdapter } from "@themvora/core";
function createSessionStoragePersistence(
key = "theme-selection",
): ThemeSelectionPersistenceAdapter {
return {
get() {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
try {
return JSON.parse(raw) as ThemeSelectionState;
} catch {
return null;
}
},
set(value) {
sessionStorage.setItem(key, JSON.stringify(value));
},
remove() {
sessionStorage.removeItem(key);
},
subscribe(listener) {
// sessionStorage doesn't fire cross-tab storage events,
// but we still implement the interface for consistency.
return () => {};
},
};
}import type { ThemeSelectionPersistenceAdapter } from "@themvora/core";
function parseCookies(header: string): Record<string, string> {
return Object.fromEntries(
header.split(";").map((c) => {
const [k, ...v] = c.trim().split("=");
return [k, decodeURIComponent(v.join("="))];
}),
);
}
function createCookiePersistence(
name = "theme-selection",
): ThemeSelectionPersistenceAdapter {
return {
get() {
const cookies = parseCookies(document.cookie);
const raw = cookies[name];
if (!raw) return null;
try {
return JSON.parse(raw) as ThemeSelectionState;
} catch {
return null;
}
},
set(value) {
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))}; path=/; max-age=31536000; SameSite=Lax`;
},
remove() {
document.cookie = `${name}=; path=/; max-age=0`;
},
subscribe(listener) {
return () => {};
},
};
}import type { ThemeSelectionPersistenceAdapter } from "@themvora/core";
// Persist the theme in the URL hash so links share the state.
function createUrlHashPersistence(): ThemeSelectionPersistenceAdapter {
return {
get() {
const hash = window.location.hash.slice(1);
if (!hash) return null;
const params = new URLSearchParams(hash);
const mode = params.get("mode");
const family = params.get("family");
if (
(mode === "light" || mode === "dark" || mode === "system") &&
family
) {
return { mode, family };
}
return null;
},
set(value) {
const params = new URLSearchParams({
mode: value.mode,
family: value.family,
});
window.location.hash = params.toString();
},
remove() {
history.replaceState(null, "", window.location.pathname);
},
subscribe(listener) {
const handler = () => listener(null); // re-read on popstate
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
},
};
}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.
// app/providers.tsx — client component
"use client";
import { useEffect, useState } from "react";
import { ThemeProvider } from "@themvora/react";
import type { ThemeSelectionState } from "@themvora/core";
export function Providers({ children }: { children: React.ReactNode }) {
const [selection, setSelection] = useState<ThemeSelectionState | null>(null);
useEffect(() => {
// Read the cookie on mount — no localStorage involved.
const cookie = document.cookie
.split("; ")
.find((c) => c.startsWith("theme-selection="));
if (cookie) {
try {
setSelection(JSON.parse(decodeURIComponent(cookie.split("=")[1])));
} catch {}
}
}, []);
return (
<ThemeProvider
defaultTheme="light"
initialMode={selection?.mode}
initialFamily={selection?.family}
>
{children}
</ThemeProvider>
);
}
// app/actions.ts — server action
"use server";
import { cookies } from "next/headers";
export async function saveThemeSelection(state: ThemeSelectionState) {
const cookieStore = await cookies();
cookieStore.set("theme-selection", JSON.stringify(state), {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}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.
const runtime = createThemeRuntime({
defaultTheme: "light",
// Pass null explicitly to disable persistence.
persistence: null,
// If you are using the plugin instead of the raw option,
// simply omit it or pass an empty array.
plugins: [],
});
// The runtime starts fresh every page load — no localStorage
// reads, no writes on theme change.6Persistence vs. sync
Persistence remembers the user's choice across visits. Sync (broadcast) propagates changes across open tabs in real-time. Use both together — they don't conflict. See the multi-window sync guide for details.
import {
createThemeRuntime,
createThemeSelectionBroadcast,
createPersistencePlugin,
} from "@themvora/core";
const runtime = createThemeRuntime({
defaultTheme: "light",
// Persistence: survives page reloads, writes to storage,
// read once on init.
plugins: [createPersistencePlugin()],
// Broadcast: syncs across tabs in real-time, no storage
// writes — messages travel through BroadcastChannel.
broadcast: createThemeSelectionBroadcast(),
});
// Tab A picks "dark" + "ocean" at 2:00 PM.
// Tab B (already open) receives the broadcast → switches instantly.
//
// User closes both tabs, comes back tomorrow.
// Tab C opens → reads localStorage → lands on "dark" + "ocean".