Modern theme management for Next.js 16+ and React 19+. Near drop-in replacement for next-themes - fixes every known bug and adds missing features. Migrating requires changing one import line.
bun add @wrksz/themes
# or
npm install @wrksz/themesstorage="hybrid": cookie-first read for SSR +localStoragemirror for cross-tab sync.createThemes(...): typed factory for provider and hooks from one canonical tuple.useThemeEffect(...): side effects on theme changes after initial render.
| next-themes | @wrksz/themes | |
|---|---|---|
| React 19 script warning | ❌ | ✅ useServerInsertedHTML |
__name minification bug |
❌ | ✅ |
Stale theme with React 19 cacheComponents |
❌ | ✅ useSyncExternalStore |
| Multi-class theme removal leaving stale classes | ❌ | ✅ |
| Nested providers | ❌ | ✅ per-instance store |
sessionStorage support |
❌ | ✅ |
cookie storage (zero-flash SSR) |
❌ | ✅ |
hybrid storage (SSR + cross-tab sync) |
❌ | ✅ |
| Disable storage | ❌ | ✅ storage="none" |
meta theme-color support |
❌ | ✅ themeColor prop |
| Server-provided theme | ❌ | ✅ initialTheme prop |
disableTransitionOnChange per property |
❌ | ✅ pass a CSS string |
| Read theme outside React | ❌ | ✅ getTheme() helper |
| Generic types | ❌ | ✅ useTheme<AppTheme>() |
| Typed factory | ❌ | ✅ createThemes(...) |
| Theme-change effect hook | ❌ | ✅ useThemeEffect(...) |
| Zero runtime dependencies | ✅ | ✅ |
Add the provider to your root layout. Import from @wrksz/themes/next for Next.js - this avoids the React 19 inline script warning by using useServerInsertedHTML. Add suppressHydrationWarning to <html> to prevent hydration mismatches.
// app/layout.tsx
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}Note:
ThemeProviderfrom@wrksz/themes/nextis an async Server Component. Use it directly inlayout.tsx- it cannot be wrapped in a"use client"component. For nested providers inside Client Components, use[ClientThemeProvider](#nested-provider-in-a-client-component).
"use client";
import { useTheme } from "@wrksz/themes/client";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
Toggle theme
</button>
);
}Use storage="cookie" with @wrksz/themes/next to eliminate SSR theme flash. The provider reads the cookie server-side automatically - no boilerplate required:
// app/layout.tsx
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider storage="cookie" defaultTheme="dark" disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
);
}For apps using CSS media queries (@media (prefers-color-scheme: dark)) alongside CSS class variables, avoid the media query fallback - the library sets the correct class before the first paint:
/* ❌ causes flash when system pref differs from stored theme */
@media (prefers-color-scheme: dark) {
:root:not(.light) { --bg: #09090b; }
}
/* ✅ */
:root { --bg: #ffffff; }
:root.dark { --bg: #09090b; }Cookie storage does not support cross-tab theme sync. Use
localStoragewithinitialThemeif you need it.
| Prop | Type | Default | Description |
|---|---|---|---|
themes |
string[] |
["light", "dark"] |
Available themes |
defaultTheme |
string |
"system" |
Theme used when no preference is stored |
forcedTheme |
string |
- | Force a specific theme, ignoring user preference |
initialTheme |
string |
- | Server-provided theme that overrides storage on mount. User can still call setTheme to change it |
enableSystem |
boolean |
true |
Detect system preference via prefers-color-scheme |
enableColorScheme |
boolean |
true |
Set native color-scheme CSS property |
attribute |
string | string[] |
"class" |
HTML attribute(s) to set on target element ("class", "data-theme", etc.) |
value |
Record<string, string> |
- | Map theme names to attribute values |
target |
string |
"html" |
Element to apply theme to ("html", "body", or a CSS selector) |
storageKey |
string |
"theme" |
Key used for storage |
storage |
"localStorage" | "sessionStorage" | "cookie" | "hybrid" | "none" |
"localStorage" |
Where to persist the theme. "hybrid" reads from cookie first and mirrors to localStorage for cross-tab sync. "cookie" reads/writes document.cookie and with @wrksz/themes/next also reads server-side for zero-flash SSR |
disableTransitionOnChange |
boolean | string |
false |
Suppress CSS transitions when switching themes. true disables all. Pass a CSS transition value (e.g. "background-color 0s, color 0s") to suppress only specific properties |
followSystem |
boolean |
false |
Always follow system preference, ignores stored value on mount |
themeColor |
string | Record<string, string> |
- | Update <meta name="theme-color"> on theme change |
nonce |
string |
- | CSP nonce for the inline script |
onThemeChange |
(theme: string) => void |
- | Called when theme changes. Receives the selected value (may be "system"). When system preference changes while theme is "system", fires with the resolved value |
const {
theme, // Current theme - may be "system"
resolvedTheme, // Actual theme - never "system"
systemTheme, // System preference: "light" | "dark" | undefined
forcedTheme, // Forced theme if set
themes, // Available themes
setTheme, // Set theme
} = useTheme();Supports generics for full type safety:
type AppTheme = "light" | "dark" | "high-contrast";
const { theme, setTheme } = useTheme<AppTheme>();
// theme: AppTheme | "system" | undefined
// setTheme: (theme: AppTheme | "system") => voidReads the current theme from a cookie outside React. Available in @wrksz/themes/next.
// proxy.ts - sync, reads from Request
import { getTheme } from "@wrksz/themes/next";
export function proxy(request: Request) {
const theme = getTheme(request, { defaultTheme: "dark" });
}
// layout.tsx - async, reads via cookies() from next/headers
const theme = await getTheme({ defaultTheme: "dark" });
return <html className={theme}>...</html>;| Option | Type | Default | Description |
|---|---|---|---|
storageKey |
string |
"theme" |
Cookie name to read from |
defaultTheme |
string |
"system" |
Returned when no valid theme is found |
themes |
string[] |
- | When provided, stored values not in the list fall back to defaultTheme |
Returns the value from a map matching the current resolved theme. Returns undefined before the theme resolves on the client.
"use client";
import { useThemeValue } from "@wrksz/themes/client";
const label = useThemeValue({ light: "Switch to dark", dark: "Switch to light" });
const bg = useThemeValue({ light: "#ffffff", dark: "#0a0a0a" });
const icon = useThemeValue({ light: <SunIcon />, dark: <MoonIcon /> });Runs an effect after mount whenever the theme changes:
"use client";
import { useThemeEffect } from "@wrksz/themes/client";
useThemeEffect((theme, resolvedTheme) => {
trackThemeChange(theme, resolvedTheme);
});Create a typed theme module once and reuse it everywhere:
"use client";
import { createThemes } from "@wrksz/themes/client";
export const { ThemeProvider, useTheme, useThemeValue, useThemeEffect } = createThemes({
themes: ["light", "dark", "high-contrast"] as const,
storage: "hybrid",
defaultTheme: "system",
attribute: "class",
});Shows different images per theme. Renders a transparent placeholder on the server to avoid hydration mismatches.
import { ThemedImage } from "@wrksz/themes/client";
<ThemedImage
src={{ light: "/logo-light.png", dark: "/logo-dark.png" }}
alt="Logo"
width={200}
height={50}
/><ThemeProvider themes={["light", "dark", "high-contrast"]}>
{children}
</ThemeProvider><ThemeProvider attribute="data-theme">
{children}
</ThemeProvider>[data-theme="dark"] { --bg: #000; }
[data-theme="light"] { --bg: #fff; }<ThemeProvider
themes={["light", "dark", "dim"]}
value={{ light: "light", dark: "dark high-contrast", dim: "dark dim" }}
>
{children}
</ThemeProvider>Switching away from "dark" correctly removes both dark and high-contrast.
// app/dashboard/layout.tsx
<ThemeProvider forcedTheme="dark">
{children}
</ThemeProvider>Apply the theme to a specific element instead of <html>, so different sections can have independent themes simultaneously:
<ThemeProvider forcedTheme="dark" target="#landing-root" storage="none">
<div id="landing-root">{children}</div>
</ThemeProvider>#landing-root { --bg: #0a0a0a; --fg: #fafafa; }Initialize from a server-side source (database, session) - overrides stored value on every mount:
export default async function RootLayout({ children }) {
const userTheme = await getUserTheme();
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider initialTheme={userTheme ?? undefined} onThemeChange={saveUserTheme}>
{children}
</ThemeProvider>
</body>
</html>
);
}"use client";
import { ClientThemeProvider } from "@wrksz/themes/client";
export function AdminShell({ children }: { children: React.ReactNode }) {
return (
<ClientThemeProvider forcedTheme="dark">
{children}
</ClientThemeProvider>
);
}// Disable all transitions
<ThemeProvider disableTransitionOnChange>
{children}
</ThemeProvider>
// Suppress only color properties, keep transform/opacity transitions intact
<ThemeProvider disableTransitionOnChange="background-color 0s, color 0s, border-color 0s">
{children}
</ThemeProvider>The convenience client barrel remains available:
import { useTheme, useThemeValue, ThemedImage } from "@wrksz/themes/client";Fine-grained client subpaths are also available for consumers who prefer direct public modules:
import { useTheme } from "@wrksz/themes/client/use-theme";
import { useThemeValue } from "@wrksz/themes/client/use-theme-value";
import { useThemeEffect } from "@wrksz/themes/client/use-theme-effect";
import { ThemedImage } from "@wrksz/themes/client/themed-image";
import { ClientThemeProvider } from "@wrksz/themes/client/provider";
import { createThemes } from "@wrksz/themes/client/create-themes";| Import | Use for |
|---|---|
@wrksz/themes/next |
ThemeProvider, getTheme in Next.js (recommended) |
@wrksz/themes/client |
useTheme, useThemeValue, useThemeEffect, createThemes, ThemedImage, ClientThemeProvider |
@wrksz/themes/client/use-theme |
Direct useTheme import |
@wrksz/themes/client/use-theme-value |
Direct useThemeValue import |
@wrksz/themes/client/use-theme-effect |
Direct useThemeEffect import |
@wrksz/themes/client/themed-image |
Direct ThemedImage import |
@wrksz/themes/client/provider |
Direct ClientThemeProvider import |
@wrksz/themes/client/create-themes |
Direct createThemes import |
@wrksz/themes |
ThemeProvider, createThemes for non-Next.js frameworks |
MIT