# @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 2026-03-30, updated 2026-09-24 · https://wrksz.dev/en/blog/wrksz-themes

---

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

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

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

## What broke under React 19

During a migration of [Hostero](https://hostero.gg/en) to Next.js 16 and React 19, next-themes started logging this in the console:

```text
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](https://github.com/pacocoursey/next-themes/pull/386), 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:

| Symptom                                                               | Cause in next-themes                                                                                                       | Fix in @wrksz/themes                                              |
| :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- |
| `<script>` warning on every render                                    | the blocking script renders inside a Client Component                                                                      | injected with `useServerInsertedHTML`, outside the component tree |
| theme stuck on a stale value under `cacheComponents`                  | state lives in `useState`                                                                                                  | a store per provider, read through `useSyncExternalStore`         |
| `ReferenceError: __name is not defined` in some production builds     | the inline script is built with `Function.toString()`, so a bundler's `__name` helper ends up in code that runs without it | the bootstrap ships as a prebuilt string                          |
| `InvalidCharacterError` with `value={{ dark: "dark high-contrast" }}` | the whole value goes to `classList` as a single token                                                                      | classes 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/themes](https://github.com/jakubwarkusz/themes)

**npm**

```bash
npm install @wrksz/themes
npm uninstall next-themes
```

**pnpm**

```bash
pnpm add @wrksz/themes
pnpm remove next-themes
```

**bun**

```bash
bun add @wrksz/themes
bun remove next-themes
```

```diff
-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:

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

### Typed themes

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

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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.

<details>
<summary>Smaller additions</summary>

- `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`

</details>

## Since then

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

- **2026-03-20** Pull request to next-themes: A fix for the React 19 script warning. Still open.
- **2026-03-21** 0.1.0 on npm: Eight releases that first day, up to 0.5.0.
- **2026-03-30** This post: Written against 0.7.9.
- **2026-04-23** 0.9.0: Hybrid storage, the `createThemes` factory and `useThemeEffect`.
- **2026-07-07** 1.0.0: A 46% smaller npm tarball after splitting the type declarations.
- **2026-08-03** 1.1.0: The first outside contributor, [@martijn00](https://github.com/martijn00), with portable SSR and client APIs.
- **2026-09-20** 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](https://themes.wrksz.dev/docs). Coming from next-themes, start with the [migration guide](https://themes.wrksz.dev/docs/migration).
