#Styling options
AddedIn the App Router, CSS is part of the module graph. Every import "./something.css" in a layout, page or component is collected by the bundler, minified, grouped into .css chunks and attached to the routes whose modules imported it. When a route renders, React puts a <link rel="stylesheet"> for each of those chunks in the <head> (React 19 hoists stylesheets and orders them by precedence), so the CSS arrives with the HTML and works with JavaScript turned off.
A useful mental model: a stylesheet follows the import, not the component. Ask two questions about any CSS file: which module imports it (that decides which route loads it) and is it scoped (a .module.css file is renamed per file, so its classes can't collide; a plain .css file is global and applies to the whole document once it's loaded). Everything else (Tailwind, Sass, CSS-in-JS) is a variation on those two.
The options, in the order the Next.js docs recommend them:
| Option | What it is | Server Components? | Cost |
|---|---|---|---|
| Tailwind CSS | utility classes generated at build time from the class names in your source | yes | one global stylesheet with only the utilities you use; needs tailwindcss + @tailwindcss/postcss |
| CSS Modules | name.module.css, class names renamed per file | yes | none, built in |
| Global CSS | plain .css imported in a layout | yes | none, but every rule is global and stays loaded |
| Sass | .scss / .sass, also as .module.scss | yes | install sass |
| CSS-in-JS | styled-components, styled-jsx, Emotion… | only in Client Components | a style registry in the root layout, runtime JS |
The example is a three-page app. The home page uses a CSS Module and renders a Client Component that has its own module; the root layout imports a global stylesheet. The Result block is what next start sent for / (curl -s localhost:4511/ | grep -o …) and the two CSS files it linked, from .next/static/chunks/. The app's other files follow the walkthrough.
import styles from "./page.module.css";
import { LikeButton } from "./like-button";
export default function Home() {
return (
<main className={styles.card}>
<h1 className={styles.title}>Home</h1>
<LikeButton />
</main>
);
}<link rel="stylesheet" href="/_next/static/chunks/2rf2--uiir30_.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/1gm7blby7ti5l.css" data-precedence="next"/>
class="_6T_DqW_card"
class="cVxzua_card"
class="cVxzua_title"
--- 2rf2--uiir30_.css (globals.css)
:root{--ink:#1f2937;--accent:#2563eb}body{color:var(--ink);margin:0;font-family:system-ui,sans-serif}
--- 1gm7blby7ti5l.css (page.module.css + like-button.module.css)
.cVxzua_card{border:1px solid var(--accent);border-radius:8px;padding:16px}.cVxzua_title{color:var(--accent);margin:0 0 8px}
._6T_DqW_card{background:#fee2e2;border-radius:999px;padding:4px 12px}- The root layout (its files follow below) imports
./globals.css. Because the root layout wraps every route, that stylesheet is on every page: the custom properties--inkand--accentand thebodyrules are global by design. page.tsx(a Server Component) importspage.module.cssand gets back an object:styles.cardandstyles.titleare strings, the renamed class names. The component runs on the server, so only those strings end up in the HTML; no CSS-in-JS runtime is involved.like-button.tsxis a Client Component and imports its own module, which also defines.card. The two.cardrules can't collide because each file's classes get the file's own prefix: in the production HTML above, the page's card iscVxzua_cardand the button's is_6T_DqW_card.- At build time both module files were merged into one chunk for
/(they're imported by the same route), andglobals.csswent into its own chunk. The HTML for/links exactly those two files;/shopand/bloglink the global chunk plus their own. - The route table shows all four routes as static
○: styling has no effect on rendering mode. CSS Modules work identically in Server and Client Components because the renaming happens at build time, not in the browser.
"use client";
import { useState } from "react";
import styles from "./like-button.module.css";
export function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<button className={styles.card} onClick={() => setLikes(likes + 1)}>
♥ {likes}
</button>
);
}.card {
padding: 16px;
border: 1px solid var(--accent);
border-radius: 8px;
}
.title {
margin: 0 0 8px;
color: var(--accent);
}.card {
padding: 4px 12px;
border-radius: 999px;
background: #fee2e2;
}:root {
--ink: #1f2937;
--accent: #2563eb;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
color: var(--ink);
}import Link from "next/link";
import "./globals.css";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<nav>
<Link href="/">Home</Link> <Link href="/shop">Shop</Link> <Link href="/blog">Blog</Link>
</nav>
{children}
</body>
</html>
);
}Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /blog
└ ○ /shop
○ (Static) prerendered as static contentUnder next dev the same classes are readable: page-module__cVxzua__card, page-module__cVxzua__title and like-button-module__6T_DqW__card (file name, a per-file hash, class name). Production keeps only the hash and the class name. Never hard-code a generated class name in tests or other CSS; select by role, text or a data- attribute instead.
You can import a plain .css file in any layout, page or component, not only the root layout. That looks like a way to scope CSS to a section of the site, but it isn't: once loaded, a global stylesheet stays in the document for the rest of the client-side session. The docs say so directly (React's stylesheet support "does not remove stylesheets as you navigate between routes"), and this example shows it.
body {
background: #fef3c7;
}import "./shop.css";
export default function ShopLayout({ children }: LayoutProps<"/shop">) {
return <section>{children}</section>;
}export default function Shop() {
return <h1>Shop</h1>;
}.post {
max-width: 60ch;
line-height: 1.6;
}import styles from "./blog.module.css";
export default function Blog() {
return (
<article className={styles.post}>
<h1>Blog</h1>
<p>Long-form text.</p>
</article>
);
}app/shop/layout.tsximportsshop.css, which sets a yellowbodybackground. The author's intent: "the shop is yellow".- A fresh load of
/shoplinks the global chunk plus0f_q5p3uid_gl.css(justbody{background:#fef3c7}); a fresh load of/bloglinks the global chunk plus its module chunk. So far each route gets only its own CSS. - Now navigate on the client: Home → Shop → Blog. Each navigation adds the new route's stylesheets to the
<head>, and none are removed, so on/blogthe shop'sbodyrule is still in the document and still matches. - The measurements below (headless Chrome,
getComputedStyle(document.body).backgroundColorand the<link>tags after each step) show the blog turning yellow after a client navigation and white after a reload. - The fix is to never write bare element or
bodyselectors in a non-root stylesheet: put section styles in a CSS Module (.shopPage) applied to the section's wrapper, or set a class ordata-attribute on a wrapper element that only that section renders.
load / sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css bg: rgba(0, 0, 0, 0)
click Shop sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css, 0f_q5p3uid_gl.css bg: rgb(254, 243, 199)
click Blog sheets: 2rf2--uiir30_.css, 1gm7blby7ti5l.css, 0f_q5p3uid_gl.css, 2no7ulbho8ayc.css bg: rgb(254, 243, 199)
reload /blog sheets: 2rf2--uiir30_.css, 2no7ulbho8ayc.css bg: rgba(0, 0, 0, 0)A user opens /shop (yellow, from shop.css imported in app/shop/layout.tsx), then clicks the Blog link. What colour is the blog's background? And if they press reload?
Show answer
Yellow after the click, white after the reload.
- The first load of
/shoprenders the root layout and the shop layout on the server; the HTML linksglobals.cssandshop.css, so the body isrgb(254, 243, 199). - Clicking Blog is a client-side navigation: the router fetches the RSC payload for
/blog, which references the blog's CSS chunk; React inserts that<link>into the existing<head>. - Nothing removes the
shop.csslink: React keeps hoisted stylesheets for the life of the document. Itsbody { background }rule still matches, so the blog is yellow (measured:rgb(254, 243, 199)with four stylesheets loaded). - Reload starts a new document. The server renders
/blogalone and links onlyglobals.cssand the blog module, so the body is transparent again (two stylesheets). - This is the exact behaviour the docs warn about, and it's why "global CSS only for truly global styles" is the rule: a bug that only appears after a particular navigation path is hard to find.
Tailwind generates utility classes (p-6, text-2xl) at build time by scanning your source files for class names. Tailwind 4 needs no config file: install it, register its PostCSS plugin and @import it in your global CSS. Because the output is plain CSS and the class names are plain strings, it works in Server Components with zero runtime, which is why the docs (and create-next-app's Tailwind option) push it as the default.
The site's template project doesn't include Tailwind, so these files aren't built by the page checker. I built them in a separate copy of the template with npm install -D tailwindcss @tailwindcss/postcss sass (it installed tailwindcss 4.3.3, @tailwindcss/postcss 4.3.3 and sass 1.106.1), and the output below is from that build.
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};@import "tailwindcss";
@theme {
--color-brand: #2563eb;
}import "./globals.css";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body className="bg-white text-gray-900">{children}</body>
</html>
);
}import styles from "./price.module.scss";
export default function Home() {
return (
<main className="mx-auto max-w-md p-6">
<h1 className="text-2xl font-bold text-brand">Tailwind in a Server Component</h1>
<p className={styles.price}>€42</p>
</main>
);
}@use "sass:color";
$sale: #dc2626;
.price {
font-weight: 700;
color: $sale;
&:hover {
color: color.adjust($sale, $lightness: -10%);
}
}@import "tailwindcss"pulls in Tailwind's base styles (preflight) and its theme variables;@theme { --color-brand: … }adds a design token, which Tailwind turns into atext-brandutility.- At build time the PostCSS plugin scans the project's files for class-like strings and emits a rule only for the utilities it finds. The page's
p-6,max-w-mdandtext-brandbecame.p-6{padding:calc(var(--spacing) * 6)},.max-w-md{max-width:var(--container-md)}and.text-brand{color:var(--color-brand)}in the built CSS. - The whole Tailwind stylesheet was 5,135 bytes (1,794 gzipped), mostly preflight; it doesn't grow with the number of components, only with the number of distinct utilities.
price.module.scssis Sass compiled first, then treated as a CSS Module: it came out as.On13OW_price{color:#dc2626;font-weight:700}.On13OW_price:hover{color:#b21d1d}in its own chunk. Sass works the same way for global.scssfiles.- Because utilities are just strings, don't build class names dynamically (
`text-${color}-600`): the scanner only sees complete class names in your source, so a constructed one has no CSS. Map values to full class names instead ({ red: "text-red-600" }).
Sass needs sass installed and nothing else; sassOptions in next.config.ts passes options such as additionalData (a prelude added to every file) or implementation: "sass-embedded". The 16 upgrade guide notes that Turbopack doesn't support Webpack's ~ prefix for imports from node_modules (@import "~bootstrap/…" becomes @import "bootstrap/…"). The build above also printed a deprecation warning until I replaced Sass's global darken() with color.adjust() from sass:color; old Sass code bases will show many of those.
Runtime CSS-in-JS libraries (styled-components, Emotion, styled-jsx) generate CSS while components render, usually through React context and hooks. Server Components have neither state nor context and never run in the browser, so these libraries only work in Client Components. Try it in a Server Component and the build stops:
export default function Home() {
return (
<div>
<p className="note">Hello</p>
<style jsx>{`
.note { color: tomato; }
`}</style>
</div>
);
}> Build error occurred
Error: Turbopack build failed with 1 error:
./src/app/page.tsx
Error: 'styled-jsx' cannot be imported from a Server Component module
It only works in a Client Component but none of its parents are marked with 'use client', so they're Server Components by default.Moving the component into a "use client" file fixes the build, but there's a second problem: during server rendering the library has nowhere to put the CSS it generated. Without a registry, the HTML I got contained the generated class (<p class="jsx-f617598c21418d8a note">) but no tomato rule anywhere, so the text renders unstyled until JavaScript loads and injects the styles. That's a flash of unstyled content, and nothing at all with JavaScript off.
The fix the docs give is a style registry: a Client Component in the root layout that collects the rules rendered so far and flushes them into the HTML with useServerInsertedHTML.
"use client";
import { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { StyleRegistry, createStyleRegistry } from "styled-jsx";
export default function StyledJsxRegistry({ children }: { children: React.ReactNode }) {
// One registry per server render, created once (lazy initial state)
const [registry] = useState(() => createStyleRegistry());
useServerInsertedHTML(() => {
const styles = registry.styles(); // the <style> elements collected so far
registry.flush();
return <>{styles}</>;
});
return <StyleRegistry registry={registry}>{children}</StyleRegistry>;
}import StyledJsxRegistry from "./registry";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<StyledJsxRegistry>{children}</StyledJsxRegistry>
</body>
</html>
);
}"use client";
export function Note({ children }: { children: React.ReactNode }) {
return (
<p className="note">
{children}
<style jsx>{`
.note {
color: tomato;
}
`}</style>
</p>
);
}import { Note } from "./note";
export default function Home() {
return <Note>Styled on the server?</Note>;
}page.tsxstays a Server Component and renders<Note>, a Client Component; Client Components are still rendered to HTML on the server, soNote's<style jsx>runs there and adds its rule to whatever styled-jsx registry is in context.StyledJsxRegistry(also a Client Component) creates that registry once per server render with lazyuseState, and provides it with<StyleRegistry>around the whole app.useServerInsertedHTMLis a Next.js hook that lets a Client Component inject HTML into the stream as it's flushed. The callback returns the collected<style>elements and clears the registry, so each chunk of streamed HTML carries the styles its components produced.- With the registry, the HTML
next startsent had the rule in the<head>, before the paragraph:<style id="__jsx-f617598c21418d8a">.note.jsx-f617598c21418d8a{color:tomato}</style>. (In my build the same<style>appeared twice in the head, back to back, in the prerenderedindex.html. It's harmless. It isn't the partial-prefetching double render: a rebuild withpartialPrefetching: falsehad the same two tags. I didn't find the cause.) - styled-components has the same shape (a
ServerStyleSheetin the registry, pluscompiler: { styledComponents: true }innext.config); the docs list which libraries support the App Router. What this demonstrates: CSS-in-JS works, but only as client code with extra plumbing and runtime cost, which is why new App Router projects lean on Tailwind or CSS Modules, or on zero-runtime libraries (vanilla-extract, StyleX, Panda) that compile to static CSS.
- Order follows imports. In production, CSS order is the order the modules were imported: if
page.tsximports<BaseButton>(which importsbase-button.module.css) beforepage.module.css, the base button's rules come first and the page's can override them. The docs recommend keeping CSS imports in a predictable entry file and turning off import-sorting lint rules, because re-sorting imports can change the cascade. - Dev can differ from production. The docs warn that CSS ordering "can behave differently in development"; always check a
next build. In this example dev served separate, unminified files per source, production merged the two modules into one chunk. - Chunking:
experimental.cssChunkingdefaults totrue(merge CSS files into fewer chunks when import order allows). Turbopack also has'graph', a cost-based grouping you tune withrequestCost(bytes one extra request is worth, default 20,000) andweightDistribution; Webpack has'strict'(keep import order, more requests) andfalse. - Inlining:
experimental.inlineCss: truereplaces the<link>tags with<style>tags in the initial HTML. It removes a render-blocking request (good for first visits and small atomic CSS like Tailwind) but stops the browser caching CSS separately (bad for returning visitors and big CSS). Production only. - External stylesheets: a package's CSS (
import "bootstrap/dist/css/bootstrap.css") can be imported anywhere inapp/, and React 19 also accepts<link rel="stylesheet" href="…" precedence="default" />in components.
- Pages Router: global CSS is imported in
pages/_app.js(the docs: "you should import them insidepages/_app.js" because of the global nature of stylesheets); CSS Modules work anywhere. Since Next.js 9.5.4, CSS fromnode_modulescan be imported in any component. - Next.js 13–15 with Webpack used the same CSS Modules idea with a different naming scheme, so generated class names in old snapshots or tests won't match Turbopack's (
cVxzua_card), which is another reason never to depend on them. - Tailwind 3 needed a
tailwind.config.jswith acontentarray of globs; Tailwind 4 detects sources automatically and is configured in CSS (@theme). The docs keep a separate Tailwind v3 guide for very old browsers.