1. What broke under React 19
  2. Drop-in replacement
  3. What it adds
  4. Cookie storage without a flash
  5. Typed themes
  6. Nested providers
  7. ThemedImage and useThemeValue
  8. Server-side access
  9. Since then
  10. Documentation
  1. What broke under React 19
  2. Drop-in replacement
  3. What it adds
  4. Cookie storage without a flash
  5. Typed themes
  6. Nested providers
  7. ThemedImage and useThemeValue
  8. Server-side access
  9. Since then
  10. Documentation
What broke under React 19
Writing

@wrksz/themes: why I rewrote next-themes from scratch

next-themes has 22 million weekly downloads and hasn't shipped in over a year. I wrote a drop-in replacement that fixes open bugs, adds cookie SSR, and supports typed themes.

Published
March 30, 2026
Updated
September 24, 2026
Reading time
4 min
Tags
next.jsreacttypescriptopen source

By March 2026, next-themes had not shipped a release in a year (0.4.6, March 2025). The numbers at that point:

weekly downloads
22M
open issues
44
pull requests waiting for review
17

Nobody was merging anything, and modern setups had started to break.

What broke under React 19#

During a migration of Hostero to Next.js 16 and React 19, next-themes started logging this in the console:

Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.

I opened a pull request, looked at the repository activity, and decided a proper rewrite of a 300-line library made more sense than maintaining a fork. The warning also turned out to be one of four problems:

SymptomCause in next-themesFix in @wrksz/themes
<script> warning on every renderthe blocking script renders inside a Client Componentinjected with useServerInsertedHTML, outside the component tree
theme stuck on a stale value under cacheComponentsstate lives in useStatea store per provider, read through useSyncExternalStore
ReferenceError: __name is not defined in some production buildsthe inline script is built with Function.toString(), so a bundler's __name helper ends up in code that runs without itthe bootstrap ships as a prebuilt string
InvalidCharacterError with value={{ dark: "dark high-contrast" }}the whole value goes to classList as a single tokenclasses are split with flatMap before they are added or removed

Drop-in replacement#

@wrksz/themes keeps the same props and hooks, so switching is mostly an install and new imports:

@wrksz/themesA modern, fully-featured theme management library for Next.js122 stars47.5K weekly downloadsv2.0.2 latest
npm install @wrksz/themes
npm uninstall next-themes
pnpm add @wrksz/themes
pnpm remove next-themes
bun add @wrksz/themes
bun remove next-themes
import { ThemeProvider } from "next-themes"; 
import { useTheme } from "next-themes"; 
import { ThemeProvider } from "@wrksz/themes/next"; 
import { useTheme } from "@wrksz/themes/client"; 

The provider import goes in the server layout, the hooks in client components. One default differs: next-themes sets data-theme, @wrksz/themes sets a class, so pass attribute="data-theme" if your CSS relies on the old behavior.

What it adds#

Cookie storage without a flash#

With storage="cookie", the theme lives in a cookie and the provider's bootstrap script reads it synchronously before the first paint, so the page never flashes the wrong theme. Nothing reads cookies on the server, so the layout can stay static:

<ThemeProvider storage="cookie" defaultTheme="dark">
  {children}
</ThemeProvider>

Typed themes#

Pass your own union and setTheme rejects anything outside it at compile time:

import { useTheme } from "@wrksz/themes/client";

type AppTheme = "light" | "dark" | "high-contrast";

const { theme, setTheme } = useTheme<AppTheme>();
setTheme("sepia");
Argument of type '"sepia"' is not assignable to parameter of type '((current: ThemeSelection<AppTheme> | undefined) => ThemeSelection<AppTheme>) | ThemeSelection<AppTheme>'.

Nested providers#

Each provider has its own store, so two parts of one page can run different themes at the same time, as long as each gets its own target and storageKey. That helps component libraries, embeds and isolated previews.

ThemedImage and useThemeValue#

ThemedImage picks the image for the current theme without a hydration mismatch:

import { ThemedImage } from "@wrksz/themes/client";

<ThemedImage
    src={{ light: "/logo-light.png", dark: "/logo-dark.png" }}
    alt="Logo"
/>

useThemeValue does the same for any value:

import { useThemeValue } from "@wrksz/themes/client";

const label = useThemeValue({
    light: "Switch to dark",
    dark: "Switch to light",
});

Server-side access#

getTheme() reads the theme cookie without pulling in React. In a proxy or middleware, pass the request:

import { NextResponse } from "next/server";
import { getTheme } from "@wrksz/themes/next";

export function proxy(request: Request) {
  const theme = getTheme(request, { defaultTheme: "dark" });
  const response = NextResponse.next();
  response.headers.set("x-theme", theme);
  return response;
}

In a Server Component, layout or server action, call it without the request and await it: await getTheme({ defaultTheme: "dark" }). Reading the cookie makes that render request-time, and the result can be "system", so resolve it before using it as a class.

Smaller additions
  • sessionStorage support
  • storage: "none" for fully controlled themes
  • meta theme-color for Safari and PWAs
  • hybrid storage: a cookie plus cross-tab sync through localStorage

Since then#

The post was written against 0.7.9. Here is how the project got to where it is now:

  1. Mar 20, 2026
    Mar 20, 2026

    Pull request to next-themes

    A fix for the React 19 script warning. Still open.
  2. Mar 21, 2026
    Mar 21, 2026

    0.1.0 on npm

    Eight releases that first day, up to 0.5.0.
  3. Mar 30, 2026
    Mar 30, 2026

    This post

    Written against 0.7.9.
  4. Apr 23, 2026
    Apr 23, 2026

    0.9.0

    Hybrid storage, the createThemes factory and useThemeEffect.
  5. Jul 7, 2026
    Jul 7, 2026

    1.0.0

    A 46% smaller npm tarball after splitting the type declarations.
  6. Aug 3, 2026
    Aug 3, 2026

    1.1.0

    The first outside contributor, @martijn00, with portable SSR and client APIs.
  7. Sep 20, 2026
    Sep 20, 2026

    2.0.0

    No more implicit cookie reads on the server under Next.js 16.3, and TypeScript 5.9 required.

Documentation#

The full API is at themes.wrksz.dev. Coming from next-themes, start with the migration guide.

New posts and notes by email.

OlderArtificial Intelligence Won't Replace Programmers
© 2026·6a5688e
Writing