Type something to search...
Next.js Using CSS-in-JS libraries

Next.js Using CSS-in-JS libraries

If you've spent any real time with styled-components, Emotion, or a similar runtime CSS-in-JS library, you already know the pitch: colocate your styles with your components, get dynamic theming for free, and never worry about class-name collisions again. That pitch worked beautifully in a plain client-rendered React app. It gets considerably more complicated the moment you move to the Next.js App Router, because the App Router doesn't render your app once on the server and hand it off to the client — it renders on the server, potentially streams that render in chunks, and then hydrates on the client, all while keeping a hard boundary between Server Components and Client Components.

Runtime CSS-in-JS libraries generate their <style> tags by executing JavaScript during render. That's fine on the client, where the DOM is sitting right there waiting to be mutated. It's a much harder problem on the server, where you need to somehow collect every style rule generated while rendering a tree, then inject them into the <head> of the HTML document before that HTML is sent to the browser — and do it again, correctly, for every chunk if the response is streamed. Get this wrong and you'll ship a page that flashes unstyled content, duplicates styles, or throws hydration errors that only show up in production.

This article walks through exactly how Next.js expects you to wire up CSS-in-JS in the App Router, why the setup looks the way it does, and where the sharp edges are that the docs don't spend much time on.

Why CSS-in-JS Needs Special Handling in the App Router

In a Server Component, there's no document, no window, and critically, no persistent module scope shared across requests the way you might assume. Every request gets its own render pass. A CSS-in-JS library that works by mutating a shared stylesheet object in memory has no natural way to know "this is a fresh request, start collecting styles again" unless you explicitly give it that signal.

On top of that, the App Router can stream HTML to the browser in pieces as different parts of the tree resolve (this is the mechanism behind loading.js and <Suspense> boundaries). If your CSS-in-JS library collects styles once, at the very end of a render, and your page is streaming, you have a real problem: by the time the first chunk of HTML reaches the browser, the styles for content further down the tree may not exist yet. The browser would happily render unstyled markup before those styles ever arrive.

Next.js solves this with a hook built specifically for this problem: useServerInsertedHTML. It lets a Client Component say "before you flush this chunk of streamed HTML, let me inject something into it first." CSS-in-JS libraries use this hook to say "collect whatever styles were generated so far, and insert them right now, before this chunk goes out the door." That's the entire trick. Everything else is plumbing to make that hook usable safely.

The Three-Step Registry Pattern

Every CSS-in-JS integration in the App Router follows the same shape, and once you've set up one library this way, the rest look almost identical:

  1. A style registry. Some object (a Map, a Set, a library-provided ServerStyleSheet, whatever the library exposes) that accumulates CSS rules as components render.
  2. useServerInsertedHTML. A Client Component hook that, on the server, flushes whatever's currently in the registry into the HTML stream, then clears the registry so the same rules don't get sent twice.
  3. A wrapping Client Component. Something that sits near the root of your tree, creates the registry once per request, and provides it to whatever mechanism the library uses to intercept style generation (a React Context, a manager component, a global side effect — it varies by library).

None of these three pieces are things you write from scratch for a well-supported library — the library itself usually ships the registry and the StyleSheetManager-style wrapper. Your job is mostly wiring: create the registry with useState's lazy initializer (so it's created exactly once per component instance, not once per render), call the hook, and mount the wrapper high enough in the tree that it captures every style generated below it.

That "created exactly once" detail matters more than it looks. If you create the registry with useState(() => new ServerStyleSheet()) — the lazy initializer form — React guarantees the initializer function only runs on the very first render of that component instance. If you instead wrote useState(new ServerStyleSheet()), you'd construct a brand new stylesheet on every single render, immediately discard it, and just get lucky that the first one survives. It works by accident until it doesn't. Always use the lazy form for anything with side effects like this.

Which Libraries Actually Work Here

Before you commit to a library, it's worth knowing where things stand, because "CSS-in-JS" is not one uniform category as far as the App Router is concerned. Some libraries need this registry dance because they generate styles at runtime during render. Others are effectively "zero-runtime" — they do their work at build time via a compiler or bundler plugin, and by the time your app runs, there's no runtime style generation left to coordinate with the server render at all.

Libraries confirmed to work in Client Components under the App Router include Ant Design, Chakra UI, Fluent UI, kuma-ui, MUI (both Material UI and Joy UI), Panda CSS, styled-jsx, styled-components, StyleX, Tamagui, tss-react, and vanilla-extract. Emotion, despite being one of the most popular CSS-in-JS libraries in the React ecosystem for years, is explicitly called out as still working toward full support — there's a long-running upstream issue tracking it. If your project is starting fresh and you were defaulting to Emotion out of habit, this is worth pausing on. You're better served picking a library from the confirmed list, or reconsidering whether you need runtime CSS-in-JS at all (more on that at the end).

It's also worth noticing which names on that list are runtime libraries versus compile-time ones. StyleX and vanilla-extract, for instance, extract static CSS at build time — they don't need the useServerInsertedHTML dance the same way styled-components does, because there's no dynamic style generation happening during your server render. If you're choosing a library today rather than maintaining an existing one, that distinction is arguably more important than which one has the prettier API, because it directly affects your runtime performance and how much of this registry machinery you actually have to maintain.

Setting Up styled-jsx

styled-jsx ships built into Next.js's compiler, which makes it feel like a "free" option, but it still needs the registry pattern if you're using it inside Client Components in the app directory. You need styled-jsx@5.1.0 or newer.

Start with a registry component:

// app/registry.tsx
"use client";

import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { StyleRegistry, createStyleRegistry } from "styled-jsx";

export default function StyledJsxRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [jsxStyleRegistry] = useState(() => createStyleRegistry());

  useServerInsertedHTML(() => {
    const styles = jsxStyleRegistry.styles();
    jsxStyleRegistry.flush();
    return <>{styles}</>;
  });

  return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>;
}

Then wrap the root layout with it:

// app/layout.tsx
import StyledJsxRegistry from "./registry";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <StyledJsxRegistry>{children}</StyledJsxRegistry>
      </body>
    </html>
  );
}

Notice that app/layout.tsx itself stays a Server Component here — you're not adding "use client" to the layout, you're importing a Client Component and rendering it. This distinction trips people up constantly, so it's worth internalizing early: wrapping your app in a client-side provider does not force your entire layout to become a Client Component. Only the wrapper itself needs the directive.

Setting Up styled-components

styled-components needs one extra step compared to styled-jsx: a Next.js compiler flag. Enable it in next.config.js:

// next.config.js
module.exports = {
  compiler: {
    styledComponents: true,
  },
};

This flag turns on a Babel/SWC transform that adds stable, readable class names and enables server-side rendering support specifically for styled-components — without it, you'll get inconsistent class names between server and client renders, which is a fast route to hydration mismatches.

With that in place, build the registry:

// lib/registry.tsx
"use client";

import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== "undefined") return <>{children}</>;

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}

And wrap the root layout the same way as before:

// app/layout.tsx
import StyledComponentsRegistry from "./lib/registry";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}

That if (typeof window !== "undefined") return <>{children}</>; line is easy to skim past, but it's doing real work. On the client, after hydration, you don't want the StyleSheetManager wrapper doing anything at all — styled-components takes over with its normal client-side runtime behavior of injecting styles directly into the DOM as components mount. The server-only registry machinery would just be redundant overhead on every subsequent client-side navigation. This check is what keeps the registry pattern scoped to server rendering, where it's actually needed.

What's Actually Happening During Streaming

This is the part that's easy to nod along to without really absorbing, so it's worth spelling out concretely. Say your page has a <Suspense> boundary around a slow data-fetching component styled with styled-components. Here's the sequence:

  1. The server starts rendering. The shell of the page (everything outside the Suspense boundary) renders first, generating some styled-components CSS along the way.
  2. Before that first chunk of HTML is sent to the browser, useServerInsertedHTML fires, pulls the accumulated styles out of the registry via getStyleElement(), and injects them into the response. clearTag() then empties the registry so those same rules aren't sent again.
  3. The slow component inside the Suspense boundary resolves later, generating more styled-components CSS as it renders.
  4. When that chunk streams out, useServerInsertedHTML fires again, this time flushing only the new styles generated since the last flush.
  5. Once the client hydrates, styled-components' normal client runtime takes over completely, and any further style changes (say, from interactive state) are injected the usual client-side way.

The reason this matters practically: if you ever see a flash of unstyled content specifically for content that appears after a Suspense boundary resolves, and everything above works fine, the registry pattern is almost always the first place to look. Either the registry component isn't wrapping high enough in the tree to capture that boundary, or something downstream is creating a second, disconnected style sheet instance instead of reusing the one from context.

Why This Only Works in Client Components

It's worth being explicit about something the docs mostly imply rather than state outright: none of this lets you use styled-components or Emotion-style APIs directly inside a Server Component. The styled.div factory function, the css template literal, the theming context — all of it depends on JavaScript executing to produce style objects and class names, which is exactly the kind of runtime behavior Server Components are designed to avoid shipping to the client and, in the App Router's model, aren't really built to carry either.

In practice this means every component that actually calls styled(...) or otherwise touches the CSS-in-JS library's API needs "use client" at the top of its file, either directly or by being a child of a component that already has it. You can still have Server Components that fetch data and pass it down as props to a styled Client Component — that pattern works fine and is extremely common. What you can't do is write a Server Component that itself defines styled-components and expect it to just work, because the library's runtime simply isn't available in that environment.

This has a real architectural consequence worth planning for: if your component tree leans heavily on styled-components for everything, including simple presentational wrappers that would otherwise have no reason to be interactive, you end up converting a lot more of your tree into Client Components than the App Router's default model encourages. That's not a bug in the CSS-in-JS libraries — it's an inherent tension between "styles computed by JavaScript at render time" and "server-first rendering with minimal client JavaScript." Keeping styled components as leaf nodes, with Server Components handling data-fetching and composition above them, keeps this tension from spreading further up your tree than it needs to.

How to Verify Your Setup Actually Works

Because the failure modes here (flashes of unstyled content, hydration mismatches, duplicated style tags) don't always show up loudly, it's worth deliberately checking your setup rather than assuming it works because nothing crashed in development.

Run a production build and start it locally:

npm run build
npm run start

Development mode is more forgiving than production here — Fast Refresh and React's dev-mode warnings can mask timing issues that only appear under a real production render. With the production server running:

  • Open the page with JavaScript disabled, or view the raw HTML via curl or "View Page Source." The styles your components need should already be present in the <head> or inline in the body — if they're missing entirely, your registry isn't capturing everything, or isn't wrapping high enough in the tree.
curl -s http://localhost:3000 | grep -o "<style[^>]*>" | head -20
  • Check for duplicate style tags. If clearTag() (styled-components) or flush() (styled-jsx) isn't being called correctly, you can end up shipping the same CSS rules multiple times across streamed chunks, which bloats the HTML response without breaking anything visibly — the kind of issue that only shows up when someone looks at network payload size months later.

  • Open the browser console and watch for hydration warnings on first load, particularly anything mentioning mismatched className attributes. This is the most common symptom of the styled-components compiler flag being missing, or of a stylesheet instance being shared incorrectly across requests.

  • Test a page with a <Suspense> boundary specifically, ideally with the slow component's data fetch artificially delayed, and confirm there's no visible flash of unstyled content when that chunk streams in. This is the scenario the registry pattern exists to solve, so it's the one most worth deliberately exercising rather than assuming works.

None of these checks are things Next.js will do for you automatically — there's no build-time error if your registry is subtly broken, because from the framework's perspective, useServerInsertedHTML fired and returned something. Whether what it returned was correct and complete is entirely on your implementation.

Common Mistakes

Forgetting the "use client" directive on the registry itself. The registry component has to be a Client Component, because useServerInsertedHTML is a client-side hook (despite the fact that it does its meaningful work during server rendering — the naming here is a genuine source of confusion). Leave off the directive and you'll get a build error pointing at the hook import.

Creating a new stylesheet instance in the wrong place. If you accidentally instantiate new ServerStyleSheet() inside a component that re-renders on every request in a way that doesn't respect the request boundary — for instance, at module scope instead of inside the component function — you can leak style state between requests in a way that's genuinely hard to debug, because it'll often work fine in development with a single concurrent request and fail unpredictably in production under load. Always create the stylesheet inside the component function, using the lazy useState initializer.

Nesting the registry too low in the tree. If you only wrap a section of your page rather than the root layout, any styled component rendered outside that wrapper won't have its styles captured, and you'll see selectively unstyled sections. The registry needs to sit above every component that might use the library.

Assuming this pattern applies to every styling approach. If you're using Tailwind CSS, CSS Modules, or global stylesheets, none of this applies — those are handled entirely differently (Tailwind and CSS Modules produce static class names resolved by the bundler, not generated by JavaScript at render time). The registry dance is specifically a consequence of runtime CSS-in-JS, where class names and rules are computed as your components execute.

Not enabling the compiler flag for styled-components. This is the single most common styled-components-specific issue. Without compiler: { styledComponents: true } in next.config.js, you can still get things rendering, but class names generated on the server and the client won't reliably match, which shows up as React hydration warnings that seem to have nothing to do with styling at first glance.

Passing Theme Data Across the Server/Client Boundary

One question the docs don't touch on at all: where does your theme object — colors, spacing scale, breakpoints, whatever your design system defines — actually live, and how does it get to a styled-components ThemeProvider if the data driving it comes from somewhere dynamic, like a tenant's branding settings stored in a database?

The pattern that works cleanly is to fetch that data in a Server Component, as you would with any other data-fetching, and pass it down as a plain serializable prop to a Client Component that wraps ThemeProvider around your app:

// app/layout.tsx
import { getTenantTheme } from "@/lib/theme";
import { ThemeRegistry } from "./theme-registry";
import StyledComponentsRegistry from "./lib/registry";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const theme = await getTenantTheme();

  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          <ThemeRegistry theme={theme}>{children}</ThemeRegistry>
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}
// app/theme-registry.tsx
"use client";

import { ThemeProvider } from "styled-components";
import type { Theme } from "@/lib/theme";

export function ThemeRegistry({
  theme,
  children,
}: {
  theme: Theme;
  children: React.ReactNode;
}) {
  return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

The important constraint here is that whatever you pass across that boundary has to be serializable — plain objects, arrays, strings, numbers. You can't pass a class instance, a function, or a React component as a prop from a Server Component into a Client Component, because that prop value has to be serialized into the React Server Component payload and reconstructed on the client. A theme object made of nested plain objects works fine. A theme built with, say, a class that has methods for computing derived colors would need to be converted to a plain object first.

This is also a good reason to keep the style registry (StyledComponentsRegistry) and the theme provider (ThemeRegistry) as two separate Client Components rather than combining them into one, even though it's tempting to merge them. The registry's job is purely mechanical — capturing and flushing CSS during server rendering — while the theme provider's job is providing actual design data to your components. Keeping them separate makes each one easier to reason about, and means you can swap or test your theming logic without touching the CSS-in-JS plumbing at all.

When You Might Not Want CSS-in-JS At All

I'll say the quiet part out loud: if you're starting a new Next.js project today, and you don't have an existing investment in styled-components or a similar library, I'd think hard before reaching for runtime CSS-in-JS at all. The App Router's rendering model — server rendering, streaming, React Server Components with no client-side JavaScript runtime by default — is fundamentally friendlier to styling approaches that don't need to execute JavaScript to produce a stylesheet.

Tailwind CSS (which this very site runs on) resolves all of its class names at build time. There's no registry, no useServerInsertedHTML, no server/client coordination problem to solve, because the CSS already exists as a static file by the time any request comes in. CSS Modules work the same way. Even within the CSS-in-JS family, the zero-runtime options — vanilla-extract and StyleX in particular — get you the type-safe, colocated authoring experience without the runtime cost or the registry ceremony described in this article.

None of that is a knock on styled-components or Emotion as libraries — they're both excellent, and if you're maintaining a large existing codebase built around one of them, migrating away just to avoid a registry component is very likely not worth the churn. But if you're evaluating options fresh, it's worth knowing that the App Router's architecture has a genuine opinion here, even if the docs present all these libraries as equally valid options. Some of them require noticeably more moving parts to use correctly than others, and that complexity is a real, ongoing maintenance cost, not a one-time setup tax.

Key Takeaways

ScenarioWhat To Do
Using styled-jsx in a Client ComponentCreate a StyleRegistry via createStyleRegistry(), flush it with useServerInsertedHTML, wrap the root layout
Using styled-componentsEnable compiler.styledComponents in next.config.js, wrap the root layout with a ServerStyleSheet-backed registry
Choosing a library for a new projectPrefer compile-time/zero-runtime options (vanilla-extract, StyleX) or utility CSS (Tailwind) to skip the registry pattern entirely
Seeing unstyled flashes after a Suspense boundary resolvesCheck that the registry wraps the entire tree, including everything inside Suspense boundaries
Seeing hydration warnings with styled-components specificallyConfirm compiler.styledComponents: true is set in next.config.js
Considering EmotionKnow that full App Router support is still in progress upstream — pick a confirmed-supported library instead if you're starting fresh

The registry pattern looks unfamiliar the first time you meet it, but it's really just answering one question every time: "how do I make sure styles generated during a server render reach the HTML before the browser paints it, even if that render happens in pieces?" Once that clicks, wiring up any new CSS-in-JS library in the App Router is a matter of finding where that library exposes its style collection API and plugging it into the same three-step shape.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper