
Next.js App router CSS
Every Next.js project eventually needs an answer to the same question: where does the CSS go? Unlike a plain Create React App setup where there was really only ever one obvious path, the App Router supports several genuinely different styling strategies at once, and it does not force you to pick just one. You can run Tailwind CSS for 90% of your UI and drop into a CSS Module for one gnarly component, all in the same project, without any of them stepping on each other.
That flexibility is a gift, but it is also where a lot of confusion starts. New App Router projects mix global CSS, component-scoped CSS, and third-party stylesheets more often than people realize, and if you don't understand how Next.js orders and bundles those files, you will eventually hit a bug where a style "randomly" wins or loses depending on which page loaded first. This article walks through every built-in styling option the App Router supports, explains the tradeoffs between them, and covers the ordering and hydration quirks that the docs mention only in passing.
Why Styling Works Differently in the App Router
Before getting into the options themselves, it helps to understand what changed under the hood. The Pages Router rendered everything through a single _app.js entry point, and most CSS-in-JS libraries and global stylesheets worked by hooking into that single render pass. The App Router doesn't have an equivalent single entry point in the same sense — layouts and pages can be Server Components, they stream to the browser instead of rendering all at once, and React itself now has built-in support for stylesheets that integrates with Suspense.
That last part matters more than it sounds like it should. When a Client Component's stylesheet is stream-attached to a Suspense boundary, React can guarantee that the component's DOM never paints before its styles are ready, which eliminates a whole category of "flash of unstyled content" bugs that used to plague streaming SSR. The tradeoff is that this same mechanism doesn't remove old stylesheets when you navigate between routes client-side, which is exactly why the official recommendation is to keep truly global CSS extremely small and push everything else into component-scoped styles. You will see why that recommendation exists in practice a few sections down.
The other structural change is that CSS imports are now colocated with components throughout the app directory rather than centralized in one styles/ folder. A component's stylesheet, its module CSS, and its logic can all live in the same folder. This is a deliberate design choice: styles that only one component uses should live next to that component, not three folders away where nobody remembers to delete them when the component itself gets deleted.
With that context out of the way, here are the actual options.
Tailwind CSS
Tailwind is the framework's own recommended default, and for good reason: it needs zero JavaScript runtime, it plays perfectly with Server Components since it's a build-time tool, and it eliminates almost all naming decisions from your day-to-day workflow. If you're starting a new App Router project today, Tailwind is the sane default choice unless you have a specific reason not to use it.
Setup takes four steps. First, install Tailwind CSS and its PostCSS plugin:
# npm
npm install -D tailwindcss @tailwindcss/postcss
# pnpm
pnpm add -D tailwindcss @tailwindcss/postcss
# yarn
yarn add -D tailwindcss @tailwindcss/postcss
# bun
bun add -D tailwindcss @tailwindcss/postcss
Notice this is Tailwind CSS 4's install path — there's no tailwind.config.js generated automatically and no npx tailwindcss init. Version 4 moved configuration into CSS itself via @theme, and the PostCSS plugin is a separate package (@tailwindcss/postcss) rather than something bundled into the tailwindcss package directly. If you've used Tailwind before v4, this is the single biggest thing to unlearn — there's no JS config file to reach for out of the box anymore.
Second, register the plugin in your PostCSS config:
// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
Third, import Tailwind into your global stylesheet:
/* app/globals.css */
@import "tailwindcss";
And fourth, pull that stylesheet into your root layout — this is the one and only place it should be imported:
// app/layout.tsx
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
From there, utility classes are available everywhere in your component tree, including inside Server Components, since Tailwind is doing all of its work at build time and has nothing to do with client-side JavaScript:
// app/page.tsx
export default function Page() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<h1 className="text-4xl font-bold">Welcome to Next.js!</h1>
</main>
);
}
One caveat worth knowing before you commit: Tailwind CSS 4 relies on modern CSS features (native cascade layers, @property, color-mix) that don't exist in genuinely old browsers. If your analytics show meaningful traffic from Safari versions from several years back or ancient Android WebViews, you'll want the Tailwind CSS v3 setup path instead, which trades some of v4's ergonomics for wider compatibility. Most projects will never need this, but it's worth checking your actual browser support matrix before assuming v4 is safe — don't just guess.
CSS Modules
CSS Modules are the built-in answer to "I want real CSS, scoped to one component, with zero risk of a class name collision anywhere else in the app." Next.js has first-class support for them without any extra configuration — you name a file *.module.css, and the build tooling automatically generates unique class names behind the scenes.
/* app/blog/blog.module.css */
.blog {
padding: 24px;
}
// app/blog/page.tsx
import styles from "./blog.module.css";
export default function Page() {
return <main className={styles.blog}></main>;
}
The imported styles object maps your original class names to the generated, collision-proof ones (something like blog_blog__a3F2x under the hood), so you never write the generated name yourself, you just reference styles.blog. Two different components can both define a .card class in their own .module.css files and never conflict, because the actual output class names are unique per file.
This is where CSS Modules earn their keep over Tailwind: complex, highly specific one-off styling — think a custom SVG animation, a very particular grid layout for a dashboard widget, or styles you're porting from an existing design system that wasn't built around utility classes. Reaching for a wall of Tailwind utility classes to express something like a keyframe animation with multiple stages is usually more painful than just writing plain CSS in a module file. Use Tailwind for the 90% case and CSS Modules for the specific component where writing real CSS is genuinely clearer.
A subtlety that trips people up: CSS Modules work fine in both Server and Client Components. There's a common misconception that anything involving import of a non-JS asset requires "use client" — it doesn't. The import is resolved and inlined at build time, so a Server Component page can import and use a CSS Module with no client-side JavaScript involved at all.
Global CSS
Sometimes you genuinely need styles that apply to every single route — a CSS reset, base typography rules, or (as shown above) Tailwind's own base layer. For that, Next.js supports plain global stylesheets, imported exactly once, in your root layout:
/* app/global.css */
body {
padding: 20px 20px 60px;
max-width: 680px;
margin: 0 auto;
}
// app/layout.tsx
// These styles apply to every route in the application
import "./global.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Global CSS can technically be imported from any layout, page, or component in the app directory, not just the root layout — but this is exactly the gotcha mentioned earlier, and it deserves a direct explanation rather than a footnote.
Because Next.js integrates with React's built-in Suspense-aware stylesheet handling, navigating between routes client-side does not currently guarantee that a global stylesheet imported deeper in the tree gets removed when you leave that route. If you import a "global" reset from inside /dashboard/settings/page.tsx, and a user navigates from settings to a completely different part of the app, there's no reliable mechanism cleaning that stylesheet up — it can persist and quietly affect the next page's rendering, and now you're debugging a style bug that only reproduces on client-side navigation and disappears on a hard refresh. That specific failure mode is annoying enough to track down that it's worth avoiding by policy rather than by vigilance.
The practical rule: keep global CSS imports confined to the root layout, and keep the content of that global stylesheet genuinely universal — resets, Tailwind's base layer, typography defaults, CSS variables. Anything scoped to a specific page or feature belongs in a CSS Module instead, precisely because CSS Modules don't have this route-navigation cleanup ambiguity in the first place.
External Stylesheets
Third-party packages that ship their own CSS — Swiper, a calendar widget, an icon library, Bootstrap — can be imported directly from anywhere in the app directory, including from a component that's colocated deep in your file tree:
// app/layout.tsx
import "bootstrap/dist/css/bootstrap.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="container">{children}</body>
</html>
);
}
React 19 also added first-class support for <link rel="stylesheet" href="..."> as a JSX element, which React de-duplicates and hoists into the document head automatically, even when rendered from deep inside the tree. This is a genuinely useful alternative for stylesheets served from a CDN rather than bundled through your package manager — you avoid a build-time import entirely and let the browser fetch the CSS directly:
export default function Page() {
return (
<>
<link
rel="stylesheet"
href="https://cdn.example.com/widget.css"
precedence="high"
/>
<ThirdPartyWidget />
</>
);
}
The precedence prop is what tells React how to order this stylesheet relative to others when de-duplicating and hoisting — without it, React can't guarantee ordering against your other stylesheets, which matters a lot once you start mixing this pattern with Tailwind or CSS Modules on the same page.
One practical note the docs don't spell out: importing a third-party package's CSS file inside a Client Component versus a Server Component behaves the same way — this is standard CSS, not JavaScript, so it doesn't trigger the client/server boundary rules that apply to component logic. What does matter is where in your import graph it lands, which brings us to the part of this system that causes the most confusion in real projects.
Ordering and Merging
This is the section of the CSS story that the docs cover accurately but briefly, and it's the one that actually causes production bugs, so it's worth spending real time on.
Next.js automatically merges and chunks your stylesheets during production builds, and critically, the final CSS order is determined by the order your code imports things, not by file names, folder structure, or where a class is defined alphabetically. If component A imports styles-a.module.css and component B imports styles-b.module.css, and your page imports A before B, then styles-a.module.css will appear earlier in the final compiled CSS than styles-b.module.css — which matters enormously when both files define a rule targeting the same selector, because in plain CSS, later rules of equal specificity win.
// page.tsx
import { BaseButton } from "./base-button";
import styles from "./page.module.css";
export default function Page() {
return <BaseButton className={styles.primary} />;
}
// base-button.tsx
import styles from "./base-button.module.css";
export function BaseButton() {
return <button className={styles.primary} />;
}
In this example, because BaseButton is imported before page.module.css, the button's own module CSS is ordered earlier in the final stylesheet than the page's module CSS. If you later reorder those two import statements purely for stylistic reasons — say, an editor's "organize imports" action alphabetizes them — you can silently flip which rule wins in a specificity tie, with no error, no warning, and no obvious link between "I reordered two import lines" and "a button's padding changed in production."
This is exactly the kind of bug that's invisible in next dev and only shows up after next build, because CSS ordering can genuinely behave differently between development and a production build. If you've ever shipped a build where styling looked subtly different from what you saw locally, an import-order change is one of the first places to look.
A few concrete habits keep this under control:
Keep CSS imports centralized where possible. Try to route all of a feature's CSS imports through a single entry file rather than scattering them across every component in the tree — it makes the actual final order predictable at a glance instead of something you have to trace through five files.
Import global styles and Tailwind at the application root, and nowhere else. This isn't just about the Suspense cleanup issue from earlier — it also guarantees Tailwind's own cascade layers are established before any component-level CSS Module gets a chance to compete with them.
Reach for CSS Modules only when Tailwind utilities genuinely aren't enough. Every additional stylesheet you introduce is one more thing participating in this ordering system.
Pick one naming convention for your module files and stick to it — <name>.module.css next to <name>.tsx is the community default, and deviating from it (say, naming things <name>.styles.tsx inline) makes it much harder for a new contributor to guess where a component's styles live.
Extract genuinely shared styles into a shared component instead of importing the same CSS Module from five unrelated places — duplicate imports are one of the more common ways teams accidentally introduce ordering conflicts they didn't intend.
Turn off auto-sorting import linters for files where import order carries this kind of meaning. ESLint's sort-imports rule (and its equivalents in Prettier plugins or editor "organize imports" commands) is written for import readability, not CSS cascade correctness, and it has no idea that reordering two lines can change how your app looks. If your team runs one of these rules project-wide, it's worth explicitly excluding files that import CSS Modules, or at minimum, flagging this risk in your contributing docs.
Reach for cssChunking in next.config.js if you need more control. This option controls how Next.js groups CSS files into chunks during the build, and it's the escape hatch for teams that have outgrown the default chunking behavior and need something more deterministic across a large, module-heavy codebase.
Diagnosing an Ordering Bug in Practice
It helps to walk through what one of these bugs actually looks like, because "CSS order depends on import order" is easy to nod along to and much harder to recognize when it's actually happening to you.
Say you have a Card component with its own card.module.css defining .title { font-size: 18px }, used inside a Dashboard page that also has its own dashboard.module.css with an (unrelated-looking, but equally specific) .title { font-size: 14px } rule targeting a different element that happens to share a generated class collision only under unusual build conditions — or, far more commonly in real codebases, a shared Heading component gets restyled by a page-level module that was never supposed to touch it, because both selectors end up with identical specificity in the compiled output and the later one in file order wins.
The symptom in the browser is deceptively simple: you inspect the element, you see two rules with the same specificity targeting it, and the browser is applying "whichever one comes later in the stylesheet" — which is standard cascade behavior, but the file that ends up later is decided by your JavaScript import graph, not by anything visible in the CSS itself. Renaming files, reordering an import block, or extracting a component into a new file can all silently move a rule's position in the final bundle.
The fastest way to actually debug this, rather than guess at it, is:
- Run
next buildand inspect the generated.cssoutput directly (or use your browser's dev tools "Sources" panel against a locally served production build) rather than relying onnext dev, since chunking and ordering can differ between the two. - Search the compiled CSS for the conflicting selector and note which occurrence appears later in the file — that's the one currently winning.
- Trace backward from that CSS file to the component that imports it, and from there to what imports that component, until you find the actual import statement whose position determines the ordering.
- Fix the conflict at the source — usually by increasing the specificity of the rule that should win (an extra class, not
!important), or by renaming one of the colliding classes so they're no longer fighting for the same specificity tier in the first place.
Reaching for !important here is tempting and almost always the wrong move — it doesn't fix the fact that two rules are colliding, it just adds a third layer of override that the next developer will have to fight with. Fixing the actual specificity or renaming the class is more work up front but doesn't leave a landmine for later.
Development vs. Production
The behavior you observe locally is not a perfect preview of what ships. In development, next dev applies CSS changes instantly through Fast Refresh, and because this relies on JavaScript running in the browser to patch styles live, CSS technically requires JavaScript to update in development mode — that's not true in production.
In a production build (next build followed by next start), Next.js concatenates and minifies your CSS into a set of code-split .css files, sized so that each route loads only the CSS it actually needs rather than one enormous stylesheet for the whole app. And — this is the part worth internalizing — CSS in a production build loads and applies even with JavaScript completely disabled in the browser, because it ships as genuine <link> tags rather than being injected by a script. This is one of the App Router's underrated wins for resilience: a user with JavaScript blocked, or a script that failed to load on a flaky connection, still gets a fully styled page.
The practical consequence of all this is one habit worth adopting: never treat next dev as your final QA pass for styling. Run next build && next start before you ship anything where CSS ordering, chunking, or module scoping is remotely load-bearing to how the page looks. It costs two commands and catches an entire category of "why does it look different in production" surprises before your users do.
Choosing a Strategy for a Real Project
If you're setting up styling from scratch today, here's the decision in practice rather than in the abstract:
Start with Tailwind CSS as your default for essentially everything — layout, spacing, typography, color, responsive behavior. It has no runtime cost, works identically in Server and Client Components, and its utility classes mean you're rarely inventing new class names or hunting for where a .card style is defined three files away.
Drop into a CSS Module the moment you're fighting Tailwind rather than benefiting from it — usually this shows up as long, awkward utility-class strings trying to express something like a multi-step animation, an unusual grid template, or styles you're porting wholesale from an existing non-Tailwind design system. There's no shame in a component having its own .module.css file sitting right next to it.
Reserve global CSS for things that are actually global: a reset, :root CSS custom properties, base typography, and Tailwind's own imported layers. If you find yourself writing global CSS that only affects one route, that's a signal it should be a CSS Module instead.
Reach for Sass or a CSS-in-JS library only if you have a specific, pre-existing reason to — an established design system built on one of them, or team expertise you don't want to throw away. Neither is required to build a fast, well-styled App Router application, and both add either a build step (Sass) or, in some CSS-in-JS libraries' cases, a runtime cost and extra SSR configuration that Tailwind and CSS Modules simply don't have.
If you're migrating an existing project rather than starting fresh, resist the urge to convert everything to Tailwind in one pass. A Sass or styled-components codebase can coexist with Tailwind indefinitely — Next.js doesn't care that you're running more than one styling approach at once, and the ordering rules above apply the same way regardless of which combination of tools produced the CSS. A more sustainable migration path is to write all new components in Tailwind, leave existing components on their current system until you're touching them anyway for unrelated reasons, and let the Sass or CSS-in-JS footprint shrink gradually rather than committing to a big-bang rewrite that risks introducing exactly the ordering bugs described above across your entire app at once.
It's also worth being honest about when not to introduce Tailwind at all. If you've inherited a large, mature CSS-in-JS codebase with an established design token system, a component library built entirely around it, and a team fluent in that approach, ripping it out for Tailwind is a multi-week project with real regression risk for a benefit that's mostly aesthetic preference at that point. Tailwind is the right default for new projects; it is not automatically worth the migration cost for an existing one that's already working.
Key Takeaways
| Approach | Best for | Watch out for |
|---|---|---|
| Tailwind CSS | Default choice for almost everything | v4 requires reasonably modern browsers; use the v3 setup for legacy support |
| CSS Modules | Component-specific or complex one-off styles | None of Tailwind's collision risk, but still subject to import-order rules |
| Global CSS | Truly universal styles (resets, base typography, Tailwind layers) | Only import from the root layout — deeper imports risk stale styles on client-side navigation |
| External stylesheets | Third-party package CSS, CDN-hosted styles | Use <link precedence="..."> for CDN CSS so React can order and de-duplicate it correctly |
| Sass / CSS-in-JS | Existing design systems already built on them | Not required by the framework; adds a build step or runtime cost Tailwind and CSS Modules avoid |
The mental model that ties all of this together is simpler than the number of options suggests: Next.js doesn't care which styling approach you use, but it does care about import order, because that's what ultimately determines your final CSS output. Pick Tailwind as your default, use CSS Modules for the exceptions, keep global CSS confined to the root layout, and always verify the real result with a production build rather than trusting what you see in development. Get those four habits right and the rest of the system stays out of your way.


