DEV Community

Krishna Adhikari
Krishna Adhikari Subscriber

Posted on

How we engineered a better Next.js theme library

How we engineered a better Next.js theme library

There are three serious theme libraries for Next.js and React in 2026:

  • next-themes by paco — the OG. ~3 million weekly downloads. Battle-tested but unmaintained for React 19.
  • @wrksz/themes by jakubwarkusz — a clean-slate rewrite from March 2026. ~10k weekly downloads. Aims to be a near drop-in replacement for next-themes with the bugs fixed.
  • @teispace/next-themes — what my team ships. ~3.5k weekly downloads. Same goal as @wrksz/themes, different engineering choices.

I spent the last week doing a code-level audit of all three. Not just reading READMEs and comparing feature tables, but cloning the repos, reading every source file, and writing failing tests against each. This post is what I found.

The short version: every one of these libraries has real bugs that ship to production. Most of them are subtle — they don't crash, they don't error in dev, they just make your app worse in ways your users notice and your monitoring doesn't catch. The kind of stuff where someone files a GitHub issue like "sometimes the page flashes dark for half a second on load" and the maintainer says "can't reproduce" and closes it.

That's the kind of bug I want to walk you through. With line numbers. From the real code.

I'll cover five specific issues, then explain the architectural choice underneath each one. If you only read one section, make it #3 (the XSS in the inline script), because if you've shipped a next-themes competitor, you probably have it too.


Why the inline anti-FOUC script is the heart of everything

Before the bugs, you need to understand the constraint. A theme library has exactly one hard job:

Apply the right theme to <html> before any pixels paint, even though the theme value lives in a cookie or localStorage that React doesn't read until after the document has streamed.

Every other feature — the React hook, the toggle button, the SSR helper — is decoration. If the inline script is wrong, the user sees a flash of the wrong theme on every page load, and no amount of clever React code fixes it.

The pattern every library uses is the same:

<head>
  <script>
    // ...read cookie or localStorage, compute theme, set class on <html>...
  </script>
</head>
Enter fullscreen mode Exit fullscreen mode

The script runs synchronously, before the browser parses any body content, before React hydrates, before anything else happens. The whole library exists to generate that script correctly and to keep the React state in sync with what the script did.

Now the bugs.


Bug #1: next-themes and the React 19 inline script warning

If you upgrade next-themes to a React 19 project, you get this in your dev console:

Warning: A dangerouslySetInnerHTML was passed to a script element. This will trigger a Content Security Policy violation in development.

The root cause is in next-themes/src/index.tsx — the script is rendered as a literal <script dangerouslySetInnerHTML={{__html: ...}}> inside a React tree that hydrates on the client. React 19 tightened how it handles inline scripts during hydration because of an Edge-case bug where the script could re-execute. The library was never updated.

@wrksz/themes fixed this by using useServerInsertedHTML from next/navigation, which inserts the script as part of the SSR stream rather than as a hydrating React element:

// @wrksz/themes/src/providers/client-next-provider.tsx
useServerInsertedHTML(() => {
  if (inserted.current) return null;
  inserted.current = true;
  return (
    <script
      dangerouslySetInnerHTML={{ __html: getScript({...}) }}
      nonce={nonce}
    />
  );
});
Enter fullscreen mode Exit fullscreen mode

Ours uses the same primitive, but with one extra prop: noScript. The default behavior is what you'd expect — the provider injects the script via useServerInsertedHTML. But there's a subtle problem with that placement: useServerInsertedHTML inserts at the next React flush boundary, which on streamed responses is usually inside <body>, after some pixels may have already painted.

So we also export a getThemeScript() helper from /server that lets you render the script directly in <head> of your root layout, then tell the provider to skip its own injection:

// app/layout.tsx
import { ThemeProvider } from '@teispace/next-themes';
import { getTheme, getThemeScript } from '@teispace/next-themes/server';

export default async function RootLayout({ children }) {
  const initialTheme = await getTheme();
  const themeScript = getThemeScript({ attribute: 'class', initialTheme });

  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </head>
      <body>
        <ThemeProvider attribute="class" initialTheme={initialTheme ?? undefined} noScript>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

This guarantees the inline script runs before the browser paints any body pixels, regardless of how Next.js streams the response. The library handles both placements; we just believe the head placement is strictly better when you can do it.


Bug #2: __name(...) wrappers crashing the inline script

This one is genuinely sneaky.

When you write a named function in TypeScript and compile with esbuild or swc with keepNames: true (the default in most setups), the compiler injects a wrapper like this:

function themeScript(storageKey, attribute, ...) { ... }
// becomes:
var themeScript = __name(function(storageKey, attribute, ...) { ... }, "themeScript");
Enter fullscreen mode Exit fullscreen mode

__name is a runtime helper esbuild injects at the top of the bundle. It just sets Object.defineProperty(fn, 'name', { value: 'themeScript' }). Harmless — except if you take that function and call .toString() on it, you get back source like:

__name(function(storageKey, attribute, ...) { ... }, "themeScript")
Enter fullscreen mode Exit fullscreen mode

Now __name is a bundle-local identifier. It doesn't exist on window. When you inject this string into an inline <script> tag, the browser sees ReferenceError: __name is not defined, the script throws, the theme is never applied, and your user sees the wrong-theme flash on every page load.

next-themes had this bug for months. So did @wrksz/themes — they have an explicit workaround in packages/themes/src/core/script.ts:145:

const fn = themeScript.toString().replace(/\s*__name\s*\([^)]*\)\s*;?\s*/g, "");
Enter fullscreen mode Exit fullscreen mode

A regex that strips __name(...) calls from the serialized function source. It works today, but it's fragile by design: it only handles this exact wrapper pattern. If esbuild ever changes the wrapper format, or if swc starts emitting something different, or if the user has a custom Babel plugin that renames the helper — the script silently breaks again, and they ship FOUC to production.

Ours sidesteps the entire class of problem. From src/core/script.ts:55-67:

The script body is a raw string literal — NOT a serialized function. This is intentional: bundlers (esbuild, swc) inject __name(fn,"name") wrappers around named functions when keepNames is on, which crashes any deserialized function body that lacks a global __name. Storing the body as data means no bundler ever rewrites it.

The actual script is hand-written JavaScript stored as a const string. We pass the config in via a JSON object that's interpolated at serialization time. No Function.toString() anywhere in the codebase. No regex strip. The bundler treats it as opaque data and can never touch it.

Here's the cost: the script can't reference imported helpers, so it's a single ~100-line string with no type checking. We compensate with a dedicated test file (__tests__/script.test.ts) that compiles and executes the script in jsdom against every config permutation we care about.

The benefit: the entire class of "bundler ate my inline script" bugs is structurally impossible.


Bug #3: The XSS surface every theme library leaves open

This one made me sit up. I'd missed it on my first read.

Every theme library serializes user-controlled config into the inline script. The themeColor prop, the value map, the forcedTheme prop — any of these can be passed by your app code and end up inside the <script> tag as a JSON-stringified value.

Standard practice is JSON.stringify. But JSON.stringify does not escape sequences that would prematurely close an inline script tag. So if your app does this:

<ThemeProvider themeColor={{ light: userInputColor }}>
Enter fullscreen mode Exit fullscreen mode

…and userInputColor somehow contains </script><script>alert(1)</script>, then the inline script serializes to:

<script>
  !function(){var c={"tc":{"light":"</script><script>alert(1)</script>"},...};
</script>
Enter fullscreen mode Exit fullscreen mode

The browser doesn't know about JavaScript string semantics. It looks for </script> and terminates the tag on the first match. Everything after that becomes live HTML, including the injected <script>alert(1)</script> — and you've just shipped XSS via your theme prop.

@wrksz/themes has this surface open right now. Their core/script.ts:144-164 just JSON.stringifys each config value directly. There's no escaping. I tested it — pass a malicious themeColor and it pops.

This is the same class of bug as upstream next-themes issue #213, which was reported in 2022 and stayed open. The reporter understood the problem; the fix never landed.

Ours fixes it in src/core/script.ts:92-109:

function safeJson(value: unknown): string {
  return JSON.stringify(value)
    .replace(/</g, '\\u003c')        // prevents </script> breakout
    .replace(//g, '\\u2028')   // line separator: valid JSON, invalid JS
    .replace(//g, '\\u2029');  // paragraph separator: same
}
Enter fullscreen mode Exit fullscreen mode

Three tiny escapes that prevent three real attack vectors:

  1. < — every < becomes <, so </script> becomes </script>. The browser never sees a closing tag in the script body; the JS string content is unchanged.
  2. / — these are valid JSON whitespace but invalid in JS string literals. They'd crash the script at runtime. Escape them, and the runtime sees the same character it would have.

The escapes are semantically equivalent for legitimate input (the string the runtime produces is identical), so we lose nothing by being defensive.

We have a test for it. From __tests__/script.test.ts:240-258:

it('escapes </script> in user-supplied config (XSS via #213 surface)', () => {
  const evil = '</script><script>window.__pwn=1;</script>';
  const s = buildScript({
    forcedTheme: evil,
    themes: ['light', 'dark', evil],
    themeColor: { light: evil, dark: '#000' },
  });
  expect(s).not.toContain('</script>');
  expect(s).toContain('\\u003c/script>');
  runScript(s);
  expect((window as { __pwn?: unknown }).__pwn).toBeUndefined();
});
Enter fullscreen mode Exit fullscreen mode

If you're shipping a theme library — any theme library that inlines user-controllable config into a <script> tag — go check your code right now. This bug is invisible until someone exploits it.


Bug #4: The disable-transition <style> that re-flickers on every set

This is the subtle one. Watch closely.

A theme library that swaps class="light" for class="dark" on <html> will trigger every CSS transition that touches a color, background, border, shadow — anything. If the user has transition: background-color 200ms on <body>, the whole page does a 200ms fade between themes. That's usually unwanted on the initial swap (people want the theme to just snap into place) but desired afterward (so the toggle feels smooth).

The pattern every library uses to suppress this is the same:

  1. Inject <style>*{ transition: none !important; }</style> into <head>.
  2. Swap the class.
  3. Wait for the browser to flush the paint (requestAnimationFrame(() => requestAnimationFrame(...))).
  4. Remove the style.

Look at how @wrksz/themes does it, in packages/themes/src/providers/client-provider.tsx:142-153:

if (disableTransitionOnChange) {
  const transitionValue =
    typeof disableTransitionOnChange === "string"
      ? disableTransitionOnChange
      : "none";
  const style = document.createElement("style");
  style.textContent = `*,*::before,*::after{transition:${transitionValue}!important}`;
  document.head.appendChild(style);
  requestAnimationFrame(() =>
    requestAnimationFrame(() => document.head.removeChild(style)),
  );
}
Enter fullscreen mode Exit fullscreen mode

This runs in applyToDom, which is called from a useEffect that depends on the resolved theme. Every time the resolved theme changes — or every time React re-runs the effect for any other reason — it injects the style, paints, removes it.

The bug: it runs even when the theme didn't actually change. If you call setTheme('dark') when the theme is already 'dark', the effect re-fires, a <style> tag gets injected and removed, the browser does a layout flush, and the page flickers for one paint frame. Worst case: your component re-renders during a route transition while theme === 'dark', the effect fires, the user sees a 16ms flash for no reason.

This is the kind of bug that's invisible in dev (16ms is below the threshold of human perception) but accumulates. Across a session, your users feel a slightly less stable UI without being able to point at what.

Ours handles it in src/core/store.ts:147-183. The relevant lines:

const applied =
  value && value[next.resolvedTheme] != null ? value[next.resolvedTheme] : next.resolvedTheme;

// Compare against current DOM state so a no-op apply does not inject
// the disable-transition <style> needlessly. This is the silent flicker
// source — every re-apply was inserting + removing a style tag, even
// when the resolved theme had not changed.
const domUnchanged =
  previousApplied === applied && isAttributeAlreadyApplied(el, attribute, applied);

let restore: (() => void) | null = null;
if (!skipTransitionDisable && disableTransitionOnChange && !domUnchanged) {
  restore = disableTransition(...);
}

if (!domUnchanged) {
  applyAttribute(...);
}
Enter fullscreen mode Exit fullscreen mode

Two checks: did the resolved theme produce the same DOM string as last time we applied? And does the DOM already match that string? If both, no-op. Don't inject the style, don't write to the DOM, don't trigger anything.

We have a test that pins this behavior in __tests__/store-transition.test.ts:

it('does not inject a disable-transition <style> when setTheme is a no-op', () => {
  const store = createStore({ ...defaults(), disableTransitionOnChange: true });
  store.mount();
  store.setTheme('dark');
  const beforeCount = document.head.querySelectorAll('style').length;
  store.setTheme('dark'); // identical theme
  store.setTheme('dark'); // and again
  expect(document.head.querySelectorAll('style').length).toBe(beforeCount);
  store.unmount();
});
Enter fullscreen mode Exit fullscreen mode

If we ever regress, the test catches it.


Bug #5: The god-component provider

This isn't a runtime bug. It's an architectural one that becomes a maintenance bug.

@wrksz/themes has a 332-line ClientThemeProvider component (packages/themes/src/providers/client-provider.tsx). Inside that one component:

  • DOM apply logic (attribute setting, class toggling, color-scheme style)
  • All five storage backends (cookie / localStorage / sessionStorage / hybrid / none reads + writes)
  • System preference (prefers-color-scheme) subscription
  • storage event handler (for cross-tab sync)
  • pageshow event handler (for bfcache restore)
  • popstate event handler (for back/forward navigation)
  • Cookie serialization
  • Meta theme-color element creation and update
  • disableTransitionOnChange style injection

All wrapped in useEffect hooks, all conditional on overlapping deps, all in render-phase code where you can't unit-test any piece in isolation.

Their __tests__/client-provider.test.tsx is 622 lines because every behavior needs a full React render setup. Want to test that cookies are read on mount? Render the provider. Want to test that the storage event fires? Render the provider. Want to verify the theme-color meta tag updates? Render the provider.

The core store is 59 lines (packages/themes/src/core/store.ts):

export function createThemeStore(): ThemeStore {
  let state: ThemeState = { theme: undefined, systemTheme: undefined };
  const listeners = new Set<() => void>();
  function emit(): void { for (const listener of listeners) listener(); }
  function setState(nextState: ThemeState): void { ... }
  return { subscribe, getSnapshot, getServerSnapshot, setState, setTheme, setSystemTheme };
}
Enter fullscreen mode Exit fullscreen mode

That's the entire store. Just a state holder. All the actual work happens in the provider.

Ours splits it differently. The store (src/core/store.ts) is 362 lines and owns:

  • State and lifecycle (mount() / unmount() are public methods)
  • DOM apply with no-op short-circuit
  • View Transitions API integration
  • System + storage + pageshow subscriptions
  • Runtime-mutable props via update()

The provider component (src/providers/client.tsx, 142 lines) is essentially:

export function ClientThemeProvider(props) {
  const storeRef = useRef(/* ... */);
  if (!storeRef.current) storeRef.current = createStore(buildOptions(props));
  const store = storeRef.current;

  const state = useSyncExternalStore(store.subscribe, store.getState, getServerState);

  useEffect(() => { store.mount(); return () => store.unmount(); }, []);
  useEffect(() => { store.update(updatableProps(props)); }, [/* mutable props */]);

  return <Context.Provider value={...}>{children}</Context.Provider>;
}
Enter fullscreen mode Exit fullscreen mode

The component is presentational. All the actual logic lives in the store, which is plain TypeScript and can be unit-tested without React.

Our test files reflect this. We have separate test files for store.test.ts, dom.test.ts, resolve.test.ts, view-transition.test.ts, adapters.test.ts, script.test.ts, etc. Each one covers a single concern. The provider test is small because the provider is small.

This isn't theory — it's why we caught the no-op transition bug in the first place. The store's apply function is callable in isolation, so we could write a direct test. In a god-component design, the same test requires a full React render, two effect cycles, and you might never notice the bug because it's hiding under the noise of normal rendering.


What we built that the others don't have

So far this post has been about bugs. Let me close with what's possible when you have a clean architecture.

Storage adapters as a public interface

In @wrksz/themes, storage is a closed string union: "localStorage" | "sessionStorage" | "cookie" | "hybrid" | "none". Want to read the theme from a Redis-backed cookie service? An OS API in Tauri? An IndexedDB store? You can't — fork the library.

Ours exposes the StorageAdapter interface directly:

export interface StorageAdapter {
  get(): string | null;
  set(value: string): void;
  subscribe?(listener: (value: string | null) => void): () => void;
}

// app/my-adapter.ts
export const tauriAdapter: AdapterFactory = () => ({
  get: () => window.__TAURI__.invoke('get_theme'),
  set: (v) => window.__TAURI__.invoke('set_theme', { theme: v }),
});
Enter fullscreen mode Exit fullscreen mode

Pass it via storage="custom" and a customStorage adapter prop. The store doesn't care where the bytes come from.

View Transitions API integration

document.startViewTransition() is the browser's built-in animation API for cross-state DOM changes. It's perfect for theme swaps. None of the other libraries support it.

Ours has it in src/core/view-transition.ts. Two modes:

  • fade — a 200ms cross-fade between the old and new themes.
  • circular — a circular reveal originating from the click coordinates. The library tracks the user's last pointer event so the animation starts from the toggle button.
<ThemeProvider transition="circular">
  <App />
</ThemeProvider>

// or per-call:
setTheme('dark', { transition: 'circular' });
Enter fullscreen mode Exit fullscreen mode

Respects prefers-reduced-motion. Falls back to a direct apply if the browser doesn't support the API.

Sec-CH-Prefers-Color-Scheme client hint

First-time visitors with no cookie still get a FOUC unless you can guess their preference server-side. Modern browsers will send a Sec-CH-Prefers-Color-Scheme: light|dark header on every request — but only after the server asks for it with Accept-CH.

We expose both halves:

// middleware.ts
import { acceptClientHintsHeader } from '@teispace/next-themes/server';

export function middleware() {
  const res = NextResponse.next();
  res.headers.set('Accept-CH', acceptClientHintsHeader());
  return res;
}
Enter fullscreen mode Exit fullscreen mode

After the first navigation, subsequent requests include the hint, and getTheme() picks it up automatically. Zero-flash SSR even on first visit. None of the other libraries support this.

Sync getTheme(Request) for middleware

We shipped this last week, in v0.5.0. @wrksz/themes has had it; we did not. Now we both do:

// middleware.ts
import { getTheme } from '@teispace/next-themes/server';

export function middleware(request: NextRequest) {
  const theme = getTheme(request, { defaultTheme: 'dark' });
  // sync — no await, no next/headers
}
Enter fullscreen mode Exit fullscreen mode

The dispatch is structural — it recognizes a Request by the combination of a Headers instance and a string url, so polyfilled and framework-specific Request subclasses work too.

Codemod from next-themes

If you're already on next-themes and want to migrate:

npx jscodeshift -t node_modules/@teispace/next-themes/codemod/from-next-themes.cjs src/
Enter fullscreen mode Exit fullscreen mode

One command, all your imports rewritten. We use jscodeshift directly so the codemod ships as a .cjs file — no extra runtime, no setup.

Published with SLSA provenance

Every release of @teispace/next-themes is built and signed in GitHub Actions with npm provenance. The npm page shows a "Built and signed on GitHub Actions" badge linking back to the exact workflow run that built the tarball.

You can verify it yourself:

npm view @teispace/next-themes dist.attestations
# { url: '...', provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }
Enter fullscreen mode Exit fullscreen mode

next-themes and @wrksz/themes don't sign their releases yet. If you care about supply chain security — and you should — this is a real differentiator.


A fair tally

next-themes @wrksz/themes @teispace/next-themes
React 19 inline script warning
__name bundler-stripping bug ✅ (regex workaround) ✅ (raw string, structurally immune)
XSS escaping in inline script
No-op transition <style> injection
Per-instance store (nested providers)
Hybrid cookie + localStorage storage
sessionStorage storage
Sec-CH-Prefers-Color-Scheme hint
View Transitions API
Pluggable storage adapters
Tailwind v4 preset
Codemod from next-themes
Sync getTheme(Request) overload
createThemes() typed factory
useServerInsertedHTML script injection
Published with SLSA provenance

I tried to be honest where the others are ahead. @wrksz/themes ships a tighter bundle (77 KB vs our 242 KB) because they don't have view transitions, scoped themes, themed icons, the codemod, or the Tailwind preset. If you don't want those features, you save bytes. The runtime cost is roughly equivalent though — both libraries are tree-shakable, and importing only the parts you use gives you a similar working set.

They also have ~3× our weekly download count, which is real. Adoption matters; popular libraries get more eyes on bugs. We're new (April 2026) and they're slightly less new (March 2026); both of us are dwarfed by next-themes.


What I'd want you to take from this

If you're shipping anything that injects a <script> tag with user-controllable config, check your XSS escaping today. JSON.stringify is not enough. <, ,. Three escapes, ten seconds to add, prevents real exploits.

If you're using next-themes on React 19, you have at least three of the bugs in this post. Migrate. Either to ours or to @wrksz/themes — both are better than what you have.

If you're building a similar library, separate the store from the provider. The 332-line god component will catch up with you in six months. The 60-line store will not.

If you're choosing between ours and @wrksz/themes: read both READMEs, check both repos, pick the one whose architecture you trust more after looking at the code. I'm biased, obviously. But the answer is in the source, not in the marketing.


Try it

npm install @teispace/next-themes
# or
yarn add @teispace/next-themes
# or
pnpm add @teispace/next-themes
Enter fullscreen mode Exit fullscreen mode

Repo: github.com/teispace/npm-packages/tree/main/packages/next-themes
npm: npmjs.com/package/@teispace/next-themes

Issues, PRs, and counter-examples welcome. If I got something wrong about @wrksz/themes or next-themes, file an issue and I'll correct it.

Top comments (0)