On this page
- The styling fork in the road
- Off-the-rack vs bespoke: a mental model
- The same button, four ways
- The big comparison
- The RSC problem with runtime CSS-in-JS
- Scoping and specificity, solved differently
- Theming: CSS variables are the common denominator
- How to choose for a given project
- Common mistakes that cost hours
- Takeaways and where to go next
The styling fork in the road
Who this is for
You can write CSS, but every new project asks the same question: **plain CSS? Tailwind? styled-components? Something newer?** This article maps the five mainstream strategies in 2026, shows the *same button* in four of them, and gives you a decision list so you stop re-litigating the choice on every repo.
Styling a single component is easy. Styling 500 components across a team of 12 for three years is the real problem. Names collide. Specificity wars break out. Nobody dares delete a class because they cannot prove it is unused. The strategy you pick is really a bet about how you will *scope*, *theme*, *share*, and *delete* styles at scale.
There is no universal winner. Each approach trades developer experience against bundle size, runtime cost, and, newly important since React Server Components went mainstream, whether it even *works* on the server. Let us make those trade-offs concrete.
Off-the-rack vs bespoke: a mental model
Pick the strategy whose failure mode you can live with: utility-first gets verbose markup, runtime CSS-in-JS gets a runtime tax, and plain CSS gets naming chaos.
The same button, four ways
Nothing clarifies the trade-offs like one component built five times. Here is a primary button, same look, same hover, same disabled state, authored in four representative strategies. Read them side by side and watch *where the styling lives*.
1. CSS Modules (plain CSS, locally scoped)
.button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
color: white;
background: var(--color-primary);
transition: background 0.15s ease;
}
.button:hover {
background: var(--color-primary-hover);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}import styles from './Button.module.css';
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <button className={styles.button} {...props} />;
}2. Tailwind (utility-first)
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className="px-4 py-2 rounded-lg font-semibold text-white bg-primary transition-colors hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed"
{...props}
/>
);
}3. styled-components (runtime CSS-in-JS)
'use client';
import styled from 'styled-components';
export const Button = styled.button`
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
color: white;
background: ${(p) => p.theme.colors.primary};
transition: background 0.15s ease;
&:hover { background: ${(p) => p.theme.colors.primaryHover}; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;4. vanilla-extract (zero-runtime CSS-in-JS)
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css';
export const button = style({
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
fontWeight: 600,
color: 'white',
background: vars.colors.primary,
transition: 'background 0.15s ease',
selectors: {
'&:hover': { background: vars.colors.primaryHover },
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
},
});Spot the difference
Three of these compile to **static CSS at build time** (Modules, Tailwind, vanilla-extract). Only styled-components ships a runtime that *generates and injects* CSS in the browser as components render, that is the line that matters most in 2026.
The big comparison
Five strategies against the five questions that actually decide a project. 'RSC-friendly' means it works inside a React Server Component without a 'use client' boundary.
| Approach | Scoping | Runtime cost | RSC-friendly? | Theming | DX |
|---|---|---|---|---|---|
| Plain CSS / BEM | Global (manual via naming) | Zero | Yes | CSS variables | Familiar, but naming discipline is on you |
| CSS Modules | Local (auto-hashed) | Zero | Yes | CSS variables | Great; co-located, no new syntax |
| Tailwind (utility) | Local (atomic, no names) | Near-zero (static, deduped) | Yes | Config tokens + CSS vars | Fast once learned; markup gets noisy |
| Runtime CSS-in-JS | Local (auto-hashed) | Real (JS exec + injection) | No (needs client boundary) | JS theme object via context | Excellent ergonomics, dynamic props |
| Zero-runtime CSS-in-JS | Local (auto-hashed) | Zero (extracted at build) | Yes | Typed token contracts | Good; build setup + some constraints |
The RSC problem with runtime CSS-in-JS
Runtime CSS-in-JS fights React Server Components
Libraries like styled-components and emotion generate styles **during render in the browser** and rely on React Context for theming. Server Components do not run in the browser and cannot consume client Context, so any styled component must sit behind a `'use client'` boundary, pulling it (and its tree) out of the server-rendering path. You lose the main RSC benefit: shipping less JavaScript.
It runs deeper than a directive. The styled-components team itself recommended against using it in new React Server Component apps and the project entered maintenance mode, explicitly because the runtime model is fundamentally at odds with server rendering. Emotion works in the App Router only with extra SSR glue and still forces client boundaries. If you adopt one of these in a Next.js App Router project today, you are swimming upstream.
- Runtime cost: every styled component parses template literals, computes a hash, and injects a
<style>tag as it mounts, measurable on large, interactive trees. - Hydration overhead: the style rules must be serialized server-side and re-matched client-side, adding payload and work.
- Client boundary creep: one styled component near the top of a tree can force a large subtree to become client-rendered.
The industry response was zero-runtime CSS-in-JS: keep the authoring ergonomics (write styles in TS, get type-safe tokens) but extract everything to a static `.css` file at build time. vanilla-extract, Linaria, and Panda CSS all do this. You get scoping and typed theming with *zero* browser runtime and full RSC compatibility.
Scoping and specificity, solved differently
Every strategy is really an answer to one question: how do I stop styles from leaking? Plain CSS leaves it to you (BEM conventions, discipline). Everything else solves it mechanically.
- CSS Modules & CSS-in-JS hash class names (
.buttonbecomes.Button_button__a1b2c), so collisions are impossible by construction. - Tailwind sidesteps naming entirely, utilities are atomic and shared, so there is nothing to collide. Specificity stays flat (single class selectors), which kills specificity wars.
- The `!important` and deep-selector arms race that plagues large plain-CSS codebases simply does not happen when every selector is one flat, scoped class.
Modern CSS shrinks the gap
Native `@layer` (cascade layers), `:where()` for zero-specificity selectors, and scoped `@scope` rules now let *plain CSS* tame specificity without a tool. They do not solve naming/dead-code, but they make 'just write CSS' more viable than it was a few years ago. See [modern CSS layout: Flexbox, Grid, and container queries](/blog/modern-css-grid-flexbox-container-queries).
Theming: CSS variables are the common denominator
Whatever strategy you pick, runtime theming in 2026 should ride on CSS custom properties, not a JavaScript theme object. CSS variables cascade, respond to media queries and data-theme attributes, and switch themes with zero re-render, no React Context, no JS execution.
:root {
--color-primary: #ec4899;
--color-primary-hover: #db2777;
--color-bg: #ffffff;
}
[data-theme='dark'] {
--color-bg: #0a0a0a;
--color-primary: #f472b6;
}Now *every* approach consumes the same source of truth: Tailwind maps tokens to utilities, CSS Modules and plain CSS use var(--color-primary), and zero-runtime libraries generate typed contracts that compile down to the same variables. Switching to dark mode is one attribute flip on <html>, instant, no flash, no JS.
The JS theme-object trap
Runtime CSS-in-JS theming via `<ThemeProvider>` puts your tokens in React Context. Changing the theme re-renders the subtree and cannot be read from a Server Component. Prefer CSS variables and reserve the JS object for build-time token definitions only. For the full token system, see [design tokens and theming](/blog/design-tokens-and-theming).
How to choose for a given project
There is no single right answer, but the decision is fast once you anchor on your rendering model and team. Walk this list top to bottom and stop at the first match.
- Next.js App Router / heavy RSC, shipping fast? Use Tailwind. Zero runtime, RSC-native, huge ecosystem, and the verbose-markup complaint is real but manageable with component extraction.
- **Want CSS-in-JS ergonomics *and* RSC compatibility? Use a zero-runtime library, vanilla-extract (typed, framework-agnostic), Linaria (familiar styled syntax), or Panda CSS** (Chakra's successor, recipes + tokens).
- Small / co-located, no new deps, plain CSS is fine? Use CSS Modules, zero runtime, RSC-safe, no learning curve, ships with every framework.
- Pure SPA (Vite/CRA), no server components, team loves the styled API? Runtime CSS-in-JS (emotion) is still acceptable here, the RSC penalty does not apply.
- A design system shared across many apps? Lean zero-runtime + typed tokens for type safety and portability, or Tailwind with a shared preset for speed.
The choice in one line
- Default to **Tailwind** or **CSS Modules** for new RSC apps, both are zero-runtime and server-safe.
- Reach for **zero-runtime CSS-in-JS** (vanilla-extract / Panda / Linaria) when you want typed styles-in-TS without the runtime tax.
- Only reach for **runtime CSS-in-JS** in a pure SPA with no server components.
Common mistakes that cost hours
- Adopting runtime CSS-in-JS in a Next.js App Router project, then fighting
'use client'boundaries and SSR flicker for weeks. Check your rendering model *first*. - Theming through a JS context object when CSS variables would switch themes with zero re-render and work in Server Components.
- Treating Tailwind's long `className` as a code smell and 'fixing' it with
@applyeverywhere, you recreate the naming/dead-code problem you escaped. Extract a *component*, not a class. - Mixing three strategies in one codebase without a rule, so every file is a coin flip. Pick one primary strategy; allow at most one escape hatch.
- Forgetting Tailwind's content/purge config, so either unused classes bloat the bundle or used classes get stripped in production.
- Inline `style={{}}` for anything beyond truly dynamic values, it skips the cascade, cannot do pseudo-states or media queries, and tanks reuse.
Takeaways and where to go next
The whole article in seven lines
- Your styling strategy is a bet on how you scope, theme, share, and delete styles at scale.
- Five options: plain CSS/BEM, CSS Modules, Tailwind (utility), runtime CSS-in-JS, zero-runtime CSS-in-JS.
- Runtime CSS-in-JS (styled-components/emotion) carries a real runtime cost and fights React Server Components.
- styled-components is in maintenance mode and is not recommended for new RSC apps.
- Zero-runtime (vanilla-extract, Linaria, Panda) keeps the ergonomics and ships static CSS, RSC-safe.
- Theme with CSS variables, not JS context: zero re-render, server-safe, instant dark mode.
- Default to Tailwind or CSS Modules for new RSC projects; choose deliberately, then stay consistent.
Styling is one layer of the frontend stack. Once you have picked a strategy, the next questions are how to lay components out and how to structure them into a reusable system.
- modern CSS layout: Flexbox, Grid, and container queries, the layout primitives your styles sit on top of.
- design tokens and theming, turn colors, spacing, and type into a single source of truth.
- component architecture and design systems, package your styled components into something a whole org can reuse.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.