Server Components and the CSS-in-JS conflict

React Server Components (RSC) changed how React applications are built when Next.js 13.4 shipped it as a stable feature in 2023. The core idea is straightforward: components are server-exclusive by default, meaning their code never reaches the browser. That opens the door to database calls and other privileged work inside components, but it also breaks assumptions baked into popular libraries — CSS-in-JS solutions like styled-components and Emotion chief among them.

Understanding the conflict requires a quick look at how server rendering works. In a traditional SSR setup, Node.js renders the full application and returns a complete HTML document. The browser then runs React again, repeating that render work to hydrate the DOM — attaching event handlers and refs to the markup the server produced. Because every component runs on both server and client, there was never a way to isolate server-only logic inside a React component.

Frameworks hacked around this. Next.js, for example, offered getServerSideProps as an escape hatch outside the React tree: the server runs that function first, and its return value becomes props for the component. It works, but it only functions at route boundaries, not anywhere you might want it.

What Server Components actually change

RSC removes that limitation by making all components server components by default. A database call can live directly in the component body:

async function Home() {
  const data = await db.query('SELECT * FROM SNEAKERS');

  return (
    <main>
      {data.map(item => (
        <Sneaker key={item.id} item={item} />
      ))}
    </main>
  );
}

That code executes only on the server. None of it ships to the client. Client Components — which the RSC model describes as components that run on both server and client — require an explicit opt-in via the "use client" directive at the top of the file:

'use client';

function Counter() {
  const [count, setCount] = React.useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

This directive establishes a client boundary. The file itself and anything it imports become Client Components, rendering on the server first and then hydrating on the client.

The catch is that Server Components don't offer the full React API. Hooks like useState and useEffect are unavailable because they depend on client-side re-rendering, which Server Components never experience. Their markup is generated once and treated as immutable by React's reconciliation process. Even useContext is off-limits until React figures out how to share context across the server/client divide.

In practice, Server Components behave more like server-rendered templates than classic React — the innovation lies in letting both types coexist in one application.

Why styled-components stumbles

CSS-in-JS libraries like styled-components tie themselves tightly to React's lifecycle. Consider this pattern:

import styled from 'styled-components';

export default function Homepage() {
  return (
    <BigRedButton>
      Click me!
    </BigRedButton>
  );
}

const BigRedButton = styled.button`
  font-size: 2rem;
  color: red;
`;

styled.button produces a new React component that renders a <button> with the attached styles. Under the hood, the library manages a <style> tag in the document head and connects styles to elements via generated class names:

<html>
  <head>
    <style data-styled="active">
      .abc123 {
        font-size: 2rem;
        color: red;
      }
    </style>
  </head>

  <body>
    <button className="abc123">
      Click me!
    </button>
  </body>
</html>

This dynamic style injection is where the trouble starts. In a client-rendered app, the browser handles everything: React creates components, and the CSS-in-JS library injects styles into its managed <style> tag. In SSR, that same process happens on the server, and the generated HTML carries the <style> tag. But styles can also be added later, as the user interacts with the app. If a styled component is conditionally rendered, its styles are only injected when that component actually appears:

function Header() {
  const user = useUser();

  return (
    <>
      {user && (
        <SignOutButton onClick={user.signOut}>
          Sign Out
        </SignOutButton>
      )}
    </>
  );
}

const SignOutButton = styled.button`
  color: white;
  background: red;
`;

Every styled component therefore acts as a React component with an extra side effect: it renders its own styles into a shared <style> tag.

The practical fallout

Styled-components rely heavily on useContext and are designed to hook into React's render lifecycle. Server Components have no such lifecycle — their code is never sent to the browser — so any component that renders even a single styled component must be a Client Component. The same goes for other CSS-in-JS libraries built on similar mechanics.

For most applications, that effectively forces nearly every component into the client boundary. In a typical codebase, the vast majority of component files contain styling, and many of those components are otherwise static — no state, no effects, nothing that genuinely requires a client runtime.

This isn't a disaster, though. Client Components still render on the server and deliver all the SSR benefits that existed before RSC. The distinction is that they also ship JavaScript to the browser and hydrate there. The goal isn't to make every component a Server Component; it's to push genuinely static markup to the server and keep the interactive parts client-side. With CSS-in-JS, you lose that optimization for styled components — but the pre-RSC baseline remains intact.

Compile-Time CSS-in-JS: Moving the Work Earlier

The idea behind zero-runtime CSS-in-JS libraries predates React Server Components. Since styled-components and its peers popularized colocating styles with components, there has been a parallel effort to shift that work from the browser to the build step. Modern React applications already compile TypeScript and JSX into optimized bundles before deployment — the argument is that style processing should happen in that same phase, not when the page loads.

Several libraries have taken up this challenge, each with its own strategy for reconciling the styled-components developer experience with the constraints of server components.

Linaria: Compiling to CSS Modules

Linaria, first released in 2017, offers an API nearly identical to styled-components:

import { styled } from '@linaria/react';

export default function Homepage() {
  return (
    <BigRedButton>
      Click me!
    </BigRedButton>
  );
}

const BigRedButton = styled.button`
  font-size: 2rem;
  color: red;
`;

The key difference is what happens at compile time. Linaria extracts all styles from the component code and transforms them into CSS Modules, turning generic class names into unique, hashed identifiers. After Linaria processes the code, it resembles plain CSS with scoped class names:

/* /components/Home.module.css */
.BigRedButton {
  font-size: 2rem;
  color: red;
}
/* /components/Home.js */
import styles from './Home.module.css';

export default function Homepage() {
  return (
    <button className={styles.BigRedButton}>
      Click me!
    </button>
  );
}

CSS Modules are a lightweight abstraction over CSS — you write standard CSS but avoid worrying about globally unique names. They are already widely supported: meta-frameworks like Next.js offer first-class CSS Module support. Rather than building a new CSS runtime from scratch, Linaria simply pre-processes styled-components syntax into that established format.

Migrating an existing styled-components codebase to Linaria is possible. When the author rebuilt his blog in 2024, he switched from styled-components to Linaria with the next-with-linaria integration package for Next.js. The experience worked but came with quirks; both Linaria and its Next.js bindings have relatively small communities. Debugging issues can mean digging into the packages yourself. It is a workable stack for experienced developers, but not one that scales well for teams needing broad community support.

Panda CSS: Compiling to Utility Classes

the Panda CSS mascot, a cute panda with a skateboard and bubble tea

Panda CSS, from the team behind Chakra UI, takes a different approach. It supports multiple interfaces — Tailwind-style shorthands like mb-5, Stitches-style variants, and a styled-components-like API:

import { styled } from '../styled-system/jsx'

export default function Homepage() {
  return (
    <BigRedButton>
      Click me!
    </BigRedButton>
  );
}

const BigRedButton = styled.button`
  font-size: 2rem;
  color: red;
`;

Instead of compiling to CSS Modules, Panda CSS generates Tailwind-style utility classes. Each unique declaration such as color: red becomes a utility class in a single central CSS file loaded across the application:

/* /styles.css */
.font-size_2rem {
  font-size: 2rem;
}
.color_red {
  color: red;
}
/* /components/Home.js */
export default function Homepage() {
  return (
    <button className="font-size_2rem color_red">
      Click me!
    </button>
  );
}

While Panda CSS benefits from an experienced team and a familiar API, the author found it lacking for one advanced pattern: cross-referencing another component's styles. With styled-components, you can specify all styles for a component in one place, include overrides for when that component appears inside a parent like an Aside or Quote:

import Link from 'next/link';

import { AsideWrapper } from '@/components/Aside';
import { QuoteWrapper } from '@/components/Quote';

const TextLink = styled(Link)`
  /* Default styles */
  color: var(--color-primary);
  text-decoration: none;

  /* Overrides, when TextLink is within a Aside */
  ${AsideWrapper} & {
    color: inherit;
    text-decoration: underline;
  }

  /* Overrides, when TextLink is within a Quote */
  ${QuoteWrapper} & {
    font-weight: var(--font-weight-bold);
    color: var(--color-secondary);
  }
`;

Because Panda CSS generates utility classes rather than unique class names per component, there is no built-in way to target another component's styles contextually. Workarounds exist — using data attributes, for instance — but they introduce enough complexity that the pattern becomes impractical. For projects that do not rely on this kind of cross-component styling, Panda CSS remains a viable option.

Pigment CSS: Material UI's Compile-Time Answer

Material UI, built on Emotion, has faced the same RSC compatibility challenges as other runtime CSS-in-JS libraries. The team's response is Pigment CSS, an open-source compile-time library whose API follows the now-familiar designer component pattern:

import { styled } from '@pigment-css/react';

export default function Homepage() {
  return (
    <BigRedButton>
      Click me!
    </BigRedButton>
  );
}

const BigRedButton = styled.button`
  font-size: 2rem;
  color: red;
`;

Pigment CSS compiles to CSS Modules, like Linaria, and ships plugins for both Next.js and Vite. It builds on WyW-in-JS ("What you Want in JS"), a low-level tool that evolved from the Linaria codebase. WyW-in-JS isolates the compile-to-CSS-Modules logic so multiple libraries can share that foundation and build their own higher-level APIs on top.

CSS Modules are battle-tested and well optimized, and the author reports that Pigment CSS offers strong performance and developer experience so far. Material UI is downloaded roughly 5 million times per week on NPM — about one-fifth of React's download count — and the next major Material UI release will support Pigment CSS, with plans to deprecate Emotion and styled-components support. Pigment CSS is young, open-sourced only in March 2024, but the backing team is investing heavily in its future.

Additional Projects Worth Watching

Beyond these three, the ecosystem continues to evolve. A few more projects the author is tracking:

  • next-yak — A drop-in styled-components replacement from developers at Switzerland's largest e-commerce retailer, re-implementing many of styled-components' secondary APIs at compile time.

  • Kuma UI — A "hybrid" approach that extracts most styles at compile time but keeps a runtime available for Client Components.

  • Parcel macros — Parcel's macro system enables compile-time CSS-in-JS, and the feature works beyond Parcel — it can be used with Next.js as well.

Does Your Application Actually Need to Change?

With all these options, the question remains: what should teams with production apps built on "legacy" runtime CSS-in-JS do? The counterintuitive answer is — in many cases, nothing.

There is a widespread impression that styled-components is incompatible with modern React or Next.js applications, or that it carries a severe performance penalty. That is largely inaccurate. Server Side Rendering works exactly as it always has; it is not affected by RSC changes. Moving to the App Router or another RSC implementation should not slow an application down — if anything, it is likely to get faster.

The real performance consideration is TTI, "Time To Interactive" — the gap between when the UI is visible and when it responds to user input. A long hydration phase produces noticeable jank: users click buttons and nothing happens while the app finishes loading in the background. If an application already has a solid TTI, adopting a zero-runtime library will not yield user-visible gains. If hydration is slow, there is a strong case for migrating.

The author suspects much of the pressure to migrate is FOMO. Adding "use client" directives throughout an existing codebase while knowing you're not fully benefitting from server components can feel like falling behind. But that feeling alone is not a sound justification for a significant migration.

Personal Experience: A Migration to App Router and Linaria

The author runs two primary production applications: this blog and a course platform. The course platform still runs Next.js Pages Router with styled-components, with no migration planned — the user experience is solid and no significant performance improvement is expected.

This blog, however, was migrated from Pages Router with styled-components to App Router with Linaria and next-with-linaria. The results were underwhelming: performance actually edged slightly worse, not better. One likely culprit — the Next.js App Router handles CSS Modules differently, pre-emptively including styles that slow down the initial request while speeding up subsequent internal navigation. For a project where initial page load matters most, that trade-off was unfavorable.

The author's conclusion is measured: React Server Components is an impressive technical achievement from the React and Vercel teams. But after living through the migration firsthand, the developer experience and user experience were both better on the previous stack. If an application performs well, there is no urgency to migrate. The ecosystem will keep maturing, and new options will likely surface, but for now the grass is not necessarily greener on the other side.