cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

CSS-in-JS and Server Components

Use styled-components, Emotion or vanilla-extract in a Next.js app: why many runtime CSS-in-JS libraries need Client Components, the style registry, zero-runtime.

By the end, you can
  • 01
    Explain the errorsTell why a CSS-in-JS library stops the build in a Server Component, and what makes a component need "use client".
  • 02
    Set up a style registryPut a registry in the root layout so the first HTML has every rule, and read the result in the HTML.
  • 03
    Pick a libraryChoose between styled-components, Emotion and a zero-runtime library like vanilla-extract for a page.

A tea card styled in JavaScript

Styling components showed CSS-in-JS: CSS written inside a component file, in JavaScript. A runtime CSS-in-JS library, like styled-components or Emotion, makes the CSS while your components render: it builds each rule from the template you wrote, gives it a class name, and puts the rule on the page. In a Next.js app, components render in two places, on the server and in the browser, and most of them are Server Components, which only render on the server. That changes what a CSS-in-JS library can do.

Here is the tea shop's tea card, styled with Emotion: @emotion/styled 11.14.1 and @emotion/react 11.14.0, the versions npm had on October 30, 2026. It sits in a real Next.js 16.4.3 project. The home page, app/page.tsx, is a Server Component, as pages are by default, and it imports the card with no "use client" file between them. No Client Component boundary starts on the way, so the card renders as a Server Component too.

The tea card with Emotion, in a Server Component (an error on purpose)Next.js
app/
app/tea-card.tsx
import styled from "@emotion/styled"; const Card = styled.div`  border: 2px solid #2f6b4f;  border-radius: 12px;  padding: 4px 16px;  background: white;`; const Price = styled.p`  color: #2f6b4f;  font-weight: bold;`; export default function TeaCard({ name, price }: { name: string; price: number }) {  return (    <Card>      <h2>{name}</h2>      <Price>${price} a box</Price>    </Card>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...Error: Failed to collect configuration for /    at ignore-listed frames {  [cause]: TypeError: g.createContext is not a function      at ignore-listed frames} > Build error occurredError: Failed to collect page data for /    at ignore-listed frames {  type: 'Error'}

Line 1 of app/tea-card.tsx imports Emotion's styled. Line 3 makes Card, a <div> with the CSS on lines 4 to 7, and line 10 makes Price, a <p> with green, bold text. package.json adds @emotion/react and @emotion/styled, as npm install writes them.

The Terminal tab shows what next build printed. It compiled the code, then stopped while it collected the data of the page at /: TypeError: g.createContext is not a function. (The g is the short name the minified code uses for React.) Emotion keeps its list of rules in React context, so its code calls createContext as soon as it loads. Server Components run with a special build of React that has no createContext, because a Server Component can't read context. The import alone stops the build.

Two halves. Left, a window titled server holds the card TeaCard, a Server Component, and under it a crossed-out card createContext, with the words build stops. Right, a window titled server and browser holds TeaCard with use client, and under it a card createContext, with the words it builds.serverTeaCard, a Server ComponentcreateContextbuild stopsserver and browserTeaCard, "use client"createContextit builds
Emotion's styled calls React's createContext. A Server Component runs with a React that has no createContext, so the build stops. With "use client", the card is a Client Component: it renders on the server for the HTML and again in the browser, with all of React.
Real-life analogyPrice labels in the tea shop

Some labels are printed before the shop opens, and they're on the shelves when the first customer walks in. Some are written by a clerk as customers pick teas up, which only works while a clerk is in the shop. And if the clerk writes the first labels in the back room, the teas go out already labeled, and nobody sees a bare shelf.

In real life: Labels printed before the shop opens, put on the shelves with the teas
In Next.js: Zero-runtime CSS-in-JS: a CSS file made by next build
In real life: A clerk who writes a label by hand when a customer picks up a tea
In Next.js: Runtime CSS-in-JS in the browser: rules made while components render
In real life: The clerk writes the first labels in the back room, and they go out with the teas
In Next.js: A style registry: rules collected during the server render, sent in the first HTML
The main idea

A runtime CSS-in-JS library makes CSS while components render, so where it runs decides what it can do. Many, like Emotion, need context and state, which only Client Components have. A style registry collects their rules during the server render and puts them in the first HTML. styled-components 6.3 and later also works in Server Components, and zero-runtime libraries make a plain CSS file at build time, which works anywhere.

Why many runtime libraries need a Client Component

Many runtime libraries, like Emotion 11.14.0 and styled-components before 6.3, do three things a Server Component can't do:

  • It shares a list of rules, and often a theme, with every styled component below it, through context. A Server Component can't create or read context.
  • It makes new rules when props or state change: a card turns orange when someone picks it. A Server Component renders once, on the server, and never again in the browser, so it has no state that changes.
  • It adds those rules to the page in the browser, with its own code, which has to be downloaded to run there.

So a component styled with Emotion, or with styled-components before 6.3, has to be a Client Component. Line 1 of this app/tea-card.tsx says "use client", and nothing else changed:

The tea card with Emotion, with "use client"Next.js
app/
app/tea-card.tsx
"use client"; import styled from "@emotion/styled"; const Card = styled.div`  border: 2px solid #2f6b4f;  border-radius: 12px;  padding: 4px 16px;  background: white;`; const Price = styled.p`  color: #2f6b4f;  font-weight: bold;`; export default function TeaCard({ name, price }: { name: string; price: number }) {  return (    <Card>      <h2>{name}</h2>      <Price>${price} a box</Price>    </Card>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/_next/static/chunks/253rcpy5dcxfv.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <style data-emotion="css ecxpv8">.css-ecxpv8{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px;background:white;}</style>
      <div class="css-ecxpv8">
        <h2>Mint tea</h2>
        <style data-emotion="css 1jx5bqu">.css-1jx5bqu{color:#2f6b4f;font-weight:bold;}</style>
        <p class="css-1jx5bqu">$<!-- -->3<!-- --> a box</p>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The build passes, and the Browser tab shows the card as real Chrome drew it: a green border and a green price. A Client Component still renders on the server for the first HTML, and again in the browser, where React hydrates it: React matches the components to the HTML already on the screen and attaches their event handlers.

Now read the Network tab, which opens first. In the body, right before the card's <div>, is a <style data-emotion="css …"> tag with the card's rule, and another before the price. With no setup, that's how Emotion gets its rules into the server's HTML: it renders a <style> tag beside each element, on the server only. The Steps tab opens the page in real Chrome, and Chrome's console shows the cost: Minified React error #418. Error #418 means the HTML from the server didn't match what React rendered in the browser, where Emotion renders no <style> tags. When hydration fails like that, React renders that part of the page again in the browser, in place of the server's HTML.

The style registry and useServerInsertedHTML

The fix that the Next.js docs give for every runtime library is a style registry: a Client Component at the top of the app that collects the rules made during the server render, and hands them to Next.js to put into the HTML. Next.js gives it a hook for that, useServerInsertedHTML, from next/navigation. Next.js calls the function you pass it while it sends the HTML, and writes what it returns into the page, before the content that needs it.

This is the registry for styled-components 6.6.0, the way the Next.js docs write it. The tea card is now a Pick card: a Client Component whose border turns orange when you click Pick.

The styled-components registry, around the root layout's childrenNext.js
app/
app/registry.tsx
"use client"; import { useState } from "react";import { useServerInsertedHTML } from "next/navigation";import { ServerStyleSheet, StyleSheetManager } from "styled-components"; export default function StyledComponentsRegistry({ children }: { children: React.ReactNode }) {  const [sheet] = useState(() => new ServerStyleSheet());   useServerInsertedHTML(() => {    const styles = sheet.getStyleElement();    sheet.instance.clearTag();    return <>{styles}</>;  });   if (typeof window !== "undefined") return <>{children}</>;   return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>;}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 17 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
    <script src="/_next/static/chunks/1e0b2t498zz0z.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/1ysrb217ipg1c.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/_next/static/chunks/2aysj1xfi0qya.js" async=""></script>
    <script src="/_next/static/chunks/0_rpegmtwf899.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <style data-styled="" data-styled-version="6.6.0">.cmbFDd{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px 16px;background:white;}/*!sc*/
data-styled.g1[id="tea-card__Card-sc-395cbd68-0"]{content:"cmbFDd,"}/*!sc*/
.hJCHbq{color:#2f6b4f;font-weight:bold;}/*!sc*/
data-styled.g2[id="tea-card__Price-sc-395cbd68-1"]{content:"hJCHbq,"}/*!sc*/
</style>
    <style data-styled="" data-styled-version="6.6.0">.cmbFDd{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px 16px;background:white;}/*!sc*/
data-styled.g1[id="tea-card__Card-sc-395cbd68-0"]{content:"cmbFDd,"}/*!sc*/
.hJCHbq{color:#2f6b4f;font-weight:bold;}/*!sc*/
data-styled.g2[id="tea-card__Price-sc-395cbd68-1"]{content:"hJCHbq,"}/*!sc*/
</style>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <div class="tea-card__Card-sc-395cbd68-0 cmbFDd">
        <h2>Mint tea</h2>
        <p class="tea-card__Price-sc-395cbd68-1 hJCHbq">$<!-- -->3<!-- --> a box</p>
        <button>Pick</button>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

In app/registry.tsx, line 1 makes it a Client Component. Line 8 makes one ServerStyleSheet, a sheet that collects rules, and keeps it in state, so every render of the registry uses the same one. Line 18 hands that sheet to every styled component below with StyleSheetManager: during the server render, they write their rules into it. Lines 10 to 14 are the hook: line 11 turns the rules collected so far into a <style> element, line 12 empties the sheet so no rule is sent twice, and line 13 returns the element for Next.js to write. In the browser, line 16 returns the children as they are, and styled-components adds its rules to the page itself. Line 9 of app/layout.tsx wraps the page in the registry, like the context provider in Composing Server and Client Components: the layout and the page stay Server Components.

The Network tab shows the result. The head of the HTML holds <style data-styled="" data-styled-version="6.6.0"> with both of the card's rules, and the body has only the elements. In this recording the tag is there twice, with the same rules. That's what Next.js 16.4.3 did here with Cache Components on, as in new projects; the same project recorded with Cache Components off has the tag once. It's recorded behavior, not something the docs promise either way, and two copies of the same rules look exactly like one.

Left, three steps one under the other with arrows between them: TeaCard renders, its rules go into the registry, useServerInsertedHTML hands them over. An arrow goes from the last step to a window titled first HTML, which holds a card for the style tag in the head, above a card for the tea card's div.1. TeaCard renders2. rules go in the registry3. useServerInsertedHTMLfirst HTML<style> in <head><div class="…">rules arrive before the card
During the server render, styled-components puts each rule in the registry's sheet. useServerInsertedHTML hands them to Next.js, which writes them into the HTML as a <style> tag in the head. The browser has the rules before it draws the card.

To see what the registry changes, take its pictures with JavaScript turned off in Chrome. That's what a visitor sees after the HTML has arrived and before the page's JavaScript has run, which on a slow phone can be a few seconds.

Playground: the first HTML, with and without a registry
The tea shop's Pick card, with this root layoutNext.js
app/
app/layout.tsx
export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
The page at /, as Chrome drew it with its styles. It says: Our teas. Mint tea. $3 a box. Pick.

A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size

Try it yourself
Without a registry

The root layout renders children as they are. The server render gives the card its class names, but nothing puts the card's rules into the HTML: before the JavaScript runs, the card has no border and the price isn't green.

Both versions were built with next build from Next.js 16.4.3 and styled-components 6.6.0. The pictures are from real Chrome with JavaScript turned off: what a visitor sees before the page's JavaScript has run. Open the Network tab to look for a <style> tag in the head. Reset goes back to the first version.

Without a registry, the card's <div> has its class names, but no rule in the HTML matches them: the picture is a plain heading, a price in black, and a grey button. When the JavaScript runs, styled-components adds the rules and the card jumps into its border. That jump is the flash of unstyled content. With the registry, the first picture is already the finished card. In both versions, the Steps tab shows Chrome clicking Pick: the border turns orange, because the card is a Client Component and styled-components makes the new rule in the browser.

Emotion needs a registry too, built on @emotion/cache. It's the same idea in more lines:

An Emotion registryNext.js
app/
app/emotion-registry.tsx
"use client"; import { useState } from "react";import { useServerInsertedHTML } from "next/navigation";import createCache from "@emotion/cache";import { CacheProvider } from "@emotion/react"; export default function EmotionRegistry({ children }: { children: React.ReactNode }) {  const [registry] = useState(() => {    const cache = createCache({ key: "tea" });    cache.compat = true;    const insert = cache.insert;    let names: string[] = [];    cache.insert = (...args) => {      const serialized = args[1];      if (cache.inserted[serialized.name] === undefined) names.push(serialized.name);      return insert(...args);    };    const flush = () => {      const flushed = names;      names = [];      return flushed;    };    return { cache, flush };  });   useServerInsertedHTML(() => {    const names = registry.flush();    if (names.length === 0) return null;    const css = names.map((name) => registry.cache.inserted[name]).join("");    return <style data-emotion={`tea ${names.join(" ")}`} dangerouslySetInnerHTML={{ __html: css }} />;  });   return <CacheProvider value={registry.cache}>{children}</CacheProvider>;}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 16 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
    <script src="/_next/static/chunks/1e0b2t498zz0z.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/1ysrb217ipg1c.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/2kvx52lmtrqyl.js" async=""></script>
    <script src="/_next/static/chunks/274vj9-oddfq6.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
    <style data-emotion="tea ecxpv8 1jx5bqu">.tea-ecxpv8{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px;background:white;}.tea-1jx5bqu{color:#2f6b4f;font-weight:bold;}</style>
    <style data-emotion="tea ecxpv8 1jx5bqu">.tea-ecxpv8{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px;background:white;}.tea-1jx5bqu{color:#2f6b4f;font-weight:bold;}</style>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <div class="tea-ecxpv8">
        <h2>Mint tea</h2>
        <p class="tea-1jx5bqu">$<!-- -->3<!-- --> a box</p>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 10 of app/emotion-registry.tsx makes a cache with its own key, tea, so the class names start with tea-. Line 11 tells Emotion to keep each rule in cache.inserted on the server instead of rendering a <style> tag beside the element. Lines 14 to 18 note the name of every new rule, line 27 hands them over with useServerInsertedHTML, and line 31 returns them as one <style> tag. Line 34 gives the cache to the styled components below with CacheProvider. The Network tab shows the rules in the head and none in the body, and the Steps tab shows Chrome opening the page with no error.

styled-components and Emotion today

styled-components 6.3.0, released in January 2026, added support for Server Components. Its release notes say it tells it's in one by the missing createContext; there it renders each rule as a <style> tag right before the element that uses it, with no context at all. This is the first tea card again, with styled-components instead of Emotion, imported by the same Server Component page with no "use client" on the way:

The tea card with styled-components, in a Server ComponentNext.js
app/
app/tea-card.tsx
import styled from "styled-components"; const Card = styled.div`  border: 2px solid #2f6b4f;  border-radius: 12px;  padding: 4px 16px;  background: white;`; const Price = styled.p`  color: #2f6b4f;  font-weight: bold;`; export default function TeaCard({ name, price }: { name: string; price: number }) {  return (    <Card>      <h2>{name}</h2>      <Price>${price} a box</Price>    </Card>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 14 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <style data-styled="">.gwuXCy{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px;background:white;}</style>
      <div class="tea-card__Card-sc-395cbd68-0 gwuXCy">
        <h2>Mint tea</h2>
        <style data-styled="">.hJCHbq{color:#2f6b4f;font-weight:bold;}</style>
        <p class="tea-card__Price-sc-395cbd68-1 hJCHbq">$<!-- -->3<!-- --> a box</p>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The code is the Emotion card's, with line 1 importing styled from styled-components. This time next build passes, and the route is ○, prerendered as static content. In the Network tab, the HTML for / has a <style data-styled=""> tag before the card's <div> and another before the price. Pick the second request, the RSC payload, the data for React that the router fetches on a client-side navigation. The same two style elements are in it, as data. A Server Component's code never reaches the browser, but what it renders does.

The recorder also searched the JavaScript files each page loads. For this page, neither the card's color #2f6b4f nor the text data-styled, which styled-components' own code contains, is in them: the library stayed on the server. For the Pick card, a Client Component, both are in the page's JavaScript.

Two limits come with it, from the styled-components docs:

  • ThemeProvider does nothing in a Server Component, because it's built on context. The docs suggest CSS variables for themes there.
  • A Server Component still can't have state. A card whose style changes when you click needs "use client", and then the registry.

Here is the second limit, recorded. The Pick card, with its useState, lost its "use client":

The Pick card without "use client" (an error on purpose)Next.js
app/
app/tea-card.tsx
import { useState } from "react";import styled from "styled-components"; const Card = styled.div<{ $picked: boolean }>`  border: 2px solid ${(props) => (props.$picked ? "#c9822b" : "#2f6b4f")};  border-radius: 12px;  padding: 4px 16px 16px;  background: white;`; const Price = styled.p`  color: #2f6b4f;  font-weight: bold;`; export default function TeaCard({ name, price }: { name: string; price: number }) {  const [picked, setPicked] = useState(false);  return (    <Card $picked={picked}>      <h2>{name}</h2>      <Price>${price} a box</Price>      <button onClick={() => setPicked(!picked)}>{picked ? "Picked" : "Pick"}</button>    </Card>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/tea-card.tsx:1:10Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client> 1 | import { useState } from "react";    |          ^^^^^^^^  2 | import styled from "styled-components";  3 |  4 | const Card = styled.div<{ $picked: boolean }>` Ecmascript file had an error Import trace:  Server Component:    ./app/tea-card.tsx    ./app/page.tsx     at ignore-listed frames

The build stops while it compiles: You're importing a module that depends on `useState` into a React Server Component module. It points at line 1, where useState is imported. The library doesn't matter: state needs a Client Component.

Emotion's packages are at @emotion/react 11.14.0 and @emotion/cache 11.14.0, from December 2024, and @emotion/styled 11.14.1, from June 2025, the latest on October 30, 2026. They have no Server Component support, and the Next.js guide to CSS-in-JS, checked on October 30, 2026, lists it as "working on support". Use it in Client Components, with a registry. Component libraries built on Emotion, like MUI, ship their own registry for the App Router. Each row compares the two libraries on one question.

styled-components and Emotion in Next.js
Questionstyled-componentsEmotion
Version checked6.6.0, on October 30, 2026.11.14.0 for @emotion/react, on October 30, 2026.
In a Server Component?Yes, since 6.3. It renders a <style> tag beside each element, and ThemeProvider does nothing there.No. The build stops with createContext is not a function.
In a Client Component?Yes, with the registry from the Next.js docs in the root layout.Yes, with a registry built on @emotion/cache.
Without a registry?The first HTML has the class names but no rules for them, until the JavaScript runs.Emotion puts <style> tags beside the elements, and React reports a hydration error, #418.
What the Next.js docs sayListed as supported, with the registry and compiler.styledComponents.Listed as "working on support".

One more line in next.config.ts: for styled-components, the Next.js docs turn on compiler.styledComponents, on lines 7 to 9 of the recorded projects' config. Next.js's compiler then gives each styled component a readable name: in the recorded HTML, the card's class attribute starts with tea-card__Card, the file and the component.

Zero-runtime: CSS files made at build time

A zero-runtime library lets you write styles in JavaScript or TypeScript, and turns them into a CSS file during next build. Nothing makes CSS while the page renders, so it doesn't matter whether a component is a Server Component or a Client Component, and there's no registry. Here is the tea card with vanilla-extract 1.21.3 and its Next.js plugin 2.5.2:

The tea card with vanilla-extractNext.js
app/
app/tea-card.css.ts
import { style } from "@vanilla-extract/css"; export const card = style({  border: "2px solid #2f6b4f",  borderRadius: 12,  padding: "4px 16px",  background: "white",}); export const price = style({  color: "#2f6b4f",  fontWeight: "bold",});
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 14 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/_next/static/chunks/2vxfn_hll6nlx.css" data-precedence="next">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <div class="_11w8j960">
        <h2>Mint tea</h2>
        <p class="_11w8j961">$<!-- -->3<!-- --> a box</p>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The stylesheet the HTML links, with the CSS Next.js built for it, laid out to read

  1. /_next/static/chunks/2vxfn_hll6nlx.css
    ._11w8j960 {
      background:#fff;
      border:2px solid #2f6b4f;
      border-radius:12px;
      padding:4px 16px
    }
    ._11w8j961 {
      color:#2f6b4f;
      font-weight:700
    }

The styles live in app/tea-card.css.ts, a TypeScript file whose name ends in .css.ts. Line 3 makes card with style(), and line 10 makes price. Each export is a class name, a plain string. Open app/tea-card.tsx: line 1 imports them, and lines 5 and 7 use them as className, in a Server Component. next.config.ts wraps the config in the plugin on line 13. Line 5 turns on its Turbopack support with unstable_turbopack, which its docs mark as experimental and only for Next.js 16 and later. Turbopack is the bundler next build uses by default.

The Network tab shows a <link rel="stylesheet"> in the head, and under the HTML, the CSS file the build made: two rules, with class names the plugin made up. No <style> tag, and the color isn't in the page's JavaScript. It's the same kind of file as a CSS Module's.

Three windows side by side. Build: tea-card.css.ts becomes a .css file; labeled zero-runtime. Server: TeaCard renders and becomes a style tag in the HTML; labeled registry or Server Component. Browser: a click on Pick becomes a new rule; labeled runtime.buildtea-card.css.tsa .css filezero-runtimeserverTeaCard renders<style> in HTMLregistry, Server ComponentbrowserPick is clickeda new ruleruntime
Zero-runtime libraries make a CSS file during next build. Runtime libraries make CSS while components render: on the server, a registry or a Server Component puts it into the HTML; in the browser, the library adds new rules when props or state change.

Two other zero-runtime libraries work the same way, with their own setup. Each row is one library, checked on October 30, 2026.

Three zero-runtime libraries
LibraryWhere you write stylesWhat Next.js needs
vanilla-extractStyles in .css.ts files, written with style({ … }).Its Next.js plugin. Turbopack support is marked experimental: unstable_turbopack.
Panda CSScss({ … }) calls in your components, read from your files.Its PostCSS plugin, and a styled-system folder that panda codegen writes.
StyleXstylex.create({ … }) in your components.Its Babel plugin and its PostCSS plugin, so Next.js compiles with Babel.

Panda CSS (2.4.0) reads your files for css() calls and writes the CSS through PostCSS, like Tailwind CSS. The css function comes from a styled-system folder that Panda generates:

The tea card with Panda CSS: code only, not built or recordedTSX
import { css } from "../styled-system/css";

export default function TeaCard({ name, price }: { name: string; price: number }) {
  return (
    <div className={css({ border: "2px solid #2f6b4f", borderRadius: "12px", padding: "4px 16px", background: "white" })}>
      <h2>{name}</h2>
      <p className={css({ color: "#2f6b4f", fontWeight: "bold" })}>${price} a box</p>
    </div>
  );
}

StyleX (0.19.1), from Meta, compiles stylex.create() into class names and writes the CSS into one file. Its Next.js guide uses a Babel plugin, and with a Babel config, Next.js compiles your code with Babel instead of its own compiler:

The tea card with StyleX: code only, not built or recordedTSX
import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
  card: {
    borderWidth: 2,
    borderStyle: "solid",
    borderColor: "#2f6b4f",
    borderRadius: 12,
    paddingBlock: 4,
    paddingInline: 16,
    backgroundColor: "white",
  },
  price: { color: "#2f6b4f", fontWeight: "bold" },
});

export default function TeaCard({ name, price }: { name: string; price: number }) {
  return (
    <div {...stylex.props(styles.card)}>
      <h2>{name}</h2>
      <p {...stylex.props(styles.price)}>${price} a box</p>
    </div>
  );
}

Unlike every other example on this page, these two snippets weren't built or recorded, and there's no result to show: Panda CSS needs panda codegen to write its styled-system folder first, and StyleX needs its Babel and PostCSS setup, which their docs walk through. They show how the code reads, from each library's docs. What they share with vanilla-extract is the result, a CSS file. The price is that styles can't depend on values known only while the page runs. For a value from props, like a tea's own color, they use a CSS variable set with style, or a fixed set of variants chosen by a class.

Playground: where does the card's CSS go?
The tea shop's tea card, styled this wayNext.js
app/
app/tea-card.tsx
import styled from "styled-components"; const Card = styled.div`  border: 2px solid #2f6b4f;  border-radius: 12px;  padding: 4px 16px;  background: white;`; const Price = styled.p`  color: #2f6b4f;  font-weight: bold;`; export default function TeaCard({ name, price }: { name: string; price: number }) {  return (    <Card>      <h2>{name}</h2>      <Price>${price} a box</Price>    </Card>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 14 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <main>
      <h1>Our teas</h1>
      <style data-styled="">.gwuXCy{border:2px solid #2f6b4f;border-radius:12px;padding:4px 16px;background:white;}</style>
      <div class="tea-card__Card-sc-395cbd68-0 gwuXCy">
        <h2>Mint tea</h2>
        <style data-styled="">.hJCHbq{color:#2f6b4f;font-weight:bold;}</style>
        <p class="tea-card__Price-sc-395cbd68-1 hJCHbq">$<!-- -->3<!-- --> a box</p>
      </div>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>
Try it yourself
styled-components, Server Component

The Server Component page imports the card with no "use client" on the way, so the card is a Server Component. styled-components renders a <style> tag right before each element that needs it, in the body. React sends those tags in the RSC payload too, as data.

Each version was built with next build from Next.js 16.4.3 and pictured in real Chrome. The Network tab shows the HTML of /: look for a <style> tag in the body, a <style> tag in the head, or a <link> to a stylesheet. Reset goes back to the first version.

Choosing a way to style the tea shop

For a new Next.js app, the docs recommend Tailwind CSS for component styles and CSS Modules for scoped CSS: both make CSS files. A CSS-in-JS library is worth it when you already use one, or a component library built on it. Then the questions are: does the component need state or context, and is the library ready for Server Components? Sort each card.

Server Component, or Client Component?
  • A tea card styled with vanilla-extract.
  • A tea card styled with a CSS Module.
  • A styled-components 6.6 card with fixed styles.
  • A tea card styled with Emotion's styled.
  • A styled-components card whose border changes when you click Pick.
  • A styled-components card that reads its colors from ThemeProvider.
Try it yourself
0 of 6 correct

Sort each tea card: does it work as a Server Component, or does it need a Client Component?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

When a card needs a Client Component only for its style, keep the boundary low: make the small part with state a Client Component, and leave the page around it on the server, as Client Components shows. Every Client Component also brings its library's code to the browser.

Common mix-ups

  • "CSS-in-JS doesn't work in Next.js." No. It works in Client Components with a registry, and styled-components 6.3 and later also works in Server Components. Zero-runtime libraries work anywhere.
  • "Adding "use client" is enough." No. The build passes, but without a registry the first HTML has no rules for styled-components' class names, and Emotion's inline tags cause a hydration error.
  • "The registry runs in the browser." No. It collects rules during the server render. In the browser it returns its children, and the library adds rules itself.
  • "A Client Component only renders in the browser." No. It renders on the server for the first HTML too, which is why its rules have to reach that HTML.
  • "styled-components in a Server Component means no CSS is sent." No. Its rules are in <style> tags in the HTML and in the RSC payload. Only the library's code stays on the server.
  • "Zero-runtime means no JavaScript at all." No. You still write styles in JavaScript or TypeScript. "Zero-runtime" means none of it runs to make CSS while the page renders.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upFix the createContext error

next build stops with createContext is not a function on this Emotion card. What do you add to app/tea-card.tsx, and where?

Starter codeTSX
import styled from "@emotion/styled";

const Card = styled.div`
  border: 2px solid #2f6b4f;
  border-radius: 12px;
  padding: 4px 16px;
  background: white;
`;

const Price = styled.p`
  color: #2f6b4f;
  font-weight: bold;
`;

export default function TeaCard({ name, price }: { name: string; price: number }) {
  return (
    <Card>
      <h2>{name}</h2>
      <Price>${price} a box</Price>
    </Card>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPut the registry in place

    This Pick card uses styled-components. You wrote app/registry.tsx from the docs. Which file renders <StyledComponentsRegistry> around the page?

    Starter codeTSX
    "use client";
    
    import { useState } from "react";
    import styled from "styled-components";
    
    const Card = styled.div<{ $picked: boolean }>`
      border: 2px solid ${(props) => (props.$picked ? "#c9822b" : "#2f6b4f")};
      border-radius: 12px;
      padding: 4px 16px 16px;
      background: white;
    `;
    
    const Price = styled.p`
      color: #2f6b4f;
      font-weight: bold;
    `;
    
    export default function TeaCard({ name, price }: { name: string; price: number }) {
      const [picked, setPicked] = useState(false);
      return (
        <Card $picked={picked}>
          <h2>{name}</h2>
          <Price>${price} a box</Price>
          <button onClick={() => setPicked(!picked)}>{picked ? "Picked" : "Pick"}</button>
        </Card>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeIs the border in the first HTML?

      This card is a Client Component, and the root layout has no registry. Is the rule for the card's green border in the HTML the server sends?

      Starter codeTSX
      "use client";
      
      import { useState } from "react";
      import styled from "styled-components";
      
      const Card = styled.div<{ $picked: boolean }>`
        border: 2px solid ${(props) => (props.$picked ? "#c9822b" : "#2f6b4f")};
        border-radius: 12px;
        padding: 4px 16px 16px;
        background: white;
      `;
      
      const Price = styled.p`
        color: #2f6b4f;
        font-weight: bold;
      `;
      
      export default function TeaCard({ name, price }: { name: string; price: number }) {
        const [picked, setPicked] = useState(false);
        return (
          <Card $picked={picked}>
            <h2>{name}</h2>
            <Price>${price} a box</Price>
            <button onClick={() => setPicked(!picked)}>{picked ? "Picked" : "Pick"}</button>
          </Card>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhich page loads the library?

        Two apps: one renders this card, a Server Component styled with styled-components, and the other renders the Pick card, with "use client" and the registry. Which card's page loads styled-components' code in the browser?

        Starter codeTSX
        import styled from "styled-components";
        
        const Card = styled.div`
          border: 2px solid #2f6b4f;
          border-radius: 12px;
          padding: 4px 16px;
          background: white;
        `;
        
        const Price = styled.p`
          color: #2f6b4f;
          font-weight: bold;
        `;
        
        export default function TeaCard({ name, price }: { name: string; price: number }) {
          return (
            <Card>
              <h2>{name}</h2>
              <Price>${price} a box</Price>
            </Card>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRemove use client from the Pick card

          Asha removed "use client" from the Pick card, because styled-components 6.6.0 works in Server Components. What happens when she runs next build?

          Starter codeTSX
          import { useState } from "react";
          import styled from "styled-components";
          
          const Card = styled.div<{ $picked: boolean }>`
            border: 2px solid ${(props) => (props.$picked ? "#c9822b" : "#2f6b4f")};
            border-radius: 12px;
            padding: 4px 16px 16px;
            background: white;
          `;
          
          const Price = styled.p`
            color: #2f6b4f;
            font-weight: bold;
          `;
          
          export default function TeaCard({ name, price }: { name: string; price: number }) {
            const [picked, setPicked] = useState(false);
            return (
              <Card $picked={picked}>
                <h2>{name}</h2>
                <Price>${price} a box</Price>
                <button onClick={() => setPicked(!picked)}>{picked ? "Picked" : "Pick"}</button>
              </Card>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMove the card to vanilla-extract

            Rewrite this Emotion card with vanilla-extract, so it can stay a Server Component with no registry. What is the file with the styles called?

            Starter codeTSX
            import styled from "@emotion/styled";
            
            const Card = styled.div`
              border: 2px solid #2f6b4f;
              border-radius: 12px;
              padding: 4px 16px;
              background: white;
            `;
            
            const Price = styled.p`
              color: #2f6b4f;
              font-weight: bold;
            `;
            
            export default function TeaCard({ name, price }: { name: string; price: number }) {
              return (
                <Card>
                  <h2>{name}</h2>
                  <Price>${price} a box</Price>
                </Card>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the component renders, and when its CSS is made.

              The CSS-in-JS quiz · 6 questionsScore: first tries count
              1. Question 1 of 6The home page, a Server Component, imports this app/tea-card.tsx, and no file on the way says "use client". What does next build do with it?

                Read the code, then predictTSX
                import styled from "@emotion/styled";
                
                const Card = styled.div`
                  border: 2px solid #2f6b4f;
                  border-radius: 12px;
                  padding: 4px 16px;
                  background: white;
                `;
                
                const Price = styled.p`
                  color: #2f6b4f;
                  font-weight: bold;
                `;
                
                export default function TeaCard({ name, price }: { name: string; price: number }) {
                  return (
                    <Card>
                      <h2>{name}</h2>
                      <Price>${price} a box</Price>
                    </Card>
                  );
                }
                

                Choose an answer to see the explanation.

              2. Question 2 of 6What does useServerInsertedHTML let a registry do?

                Choose an answer to see the explanation.

              3. Question 3 of 6A Client Component card uses styled-components, and the root layout has no registry. What does a visitor on a slow phone see first?

                Choose an answer to see the explanation.

              4. Question 4 of 6Asha removed "use client" from this card, because styled-components 6.3 works in Server Components. What happens?

                Read the code, then predictTSX
                import { useState } from "react";
                import styled from "styled-components";
                
                const Card = styled.div<{ $picked: boolean }>`
                  border: 2px solid ${(props) => (props.$picked ? "#c9822b" : "#2f6b4f")};
                  border-radius: 12px;
                  padding: 4px 16px 16px;
                  background: white;
                `;
                
                const Price = styled.p`
                  color: #2f6b4f;
                  font-weight: bold;
                `;
                
                export default function TeaCard({ name, price }: { name: string; price: number }) {
                  const [picked, setPicked] = useState(false);
                  return (
                    <Card $picked={picked}>
                      <h2>{name}</h2>
                      <Price>${price} a box</Price>
                      <button onClick={() => setPicked(!picked)}>{picked ? "Picked" : "Pick"}</button>
                    </Card>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 6Where does vanilla-extract's CSS come from in the recorded page?

                Choose an answer to see the explanation.

              6. Question 6 of 6Why does a styled-components card in a Server Component send none of the library's code to the browser?

                Choose an answer to see the explanation.

              Key takeaways

              • A runtime CSS-in-JS library makes CSS while components render. Emotion needs context, so in a Server Component the build stops with createContext is not a function.
              • In a Client Component, put a style registry in the root layout. It collects rules during the server render, and useServerInsertedHTML writes them into the first HTML, so there's no flash of unstyled content.
              • styled-components 6.3 and later renders <style> tags in Server Components, and its code stays on the server. ThemeProvider and state still need a Client Component.
              • Zero-runtime libraries, like vanilla-extract, Panda CSS and StyleX, make a CSS file at build time: no registry, and they work in any component.

              Remember the one-liner.
              CSS made while rendering needs a registry to reach the first HTML; CSS made at build time is just a file.

              Coming next: Optimizing images. The tea shop gets photos, and next/image resizes them, converts them and loads them only when they're needed.

              CompleteFrontend Clear concepts. Working examples.