How would you manage styles in large-scale apps (CSS-in-JS, CSS variables, or something else)
At scale the real problems are global scope, specificity wars, dead code, and theming. Pick a scoping strategy — CSS Modules, CSS-in-JS (runtime or zero-runtime), or utility-first (Tailwind) — layered with design tokens as CSS custom properties for theming. There's no single right answer; the senior move is matching the choice to team size, SSR needs, and performance budget.
Styling a large app isn't about picking a "best" library — it's about solving the scaling problems of plain CSS: global namespace collisions, specificity wars, dead-code accumulation, inconsistent design, and theming.
The strategies, and their trade-offs
1. CSS Modules Locally-scoped class names (styles.button → button_a3f9x). Solves global scope with zero runtime cost and plain CSS. Great default. Downside: dynamic/prop-based styling is awkward (you toggle classes).
2. CSS-in-JS (runtime — styled-components, Emotion) Styles colocated with components, full access to props/theme, dynamic styling is natural. Downside: runtime cost (style injection, serialization), SSR setup complexity, and it's fallen out of favor in the React Server Components era because it needs client JS.
3. Zero-runtime CSS-in-JS (vanilla-extract, Linaria, Panda CSS) CSS-in-JS authoring ergonomics, but styles are extracted to static CSS at build time — no runtime. Best of both for many teams; works with RSC/SSR.
4. Utility-first (Tailwind) Compose styles from utility classes. No naming, tiny purged output, enforced consistency via the config (design tokens baked in), great for big teams. Downside: verbose markup, learning curve, harder for highly dynamic one-off styling.
5. Design tokens via CSS custom properties This is orthogonal — layer it under any of the above. --color-primary, --space-md in :root give you one source of truth, runtime theming, and dark mode with no JS. The modern stack is "a scoping strategy + custom properties for tokens/theming."
How to actually decide
| Concern | Leans toward |
|---|---|
| RSC / minimal client JS | CSS Modules, zero-runtime CSS-in-JS, Tailwind |
| Highly dynamic, prop-driven styles | CSS-in-JS (runtime or zero-runtime) |
| Large team, consistency, velocity | Tailwind + token config |
| Existing CSS expertise, simplicity | CSS Modules |
| Theming / dark mode | CSS custom properties (with any of the above) |
The non-negotiables at scale (whatever you pick)
- Scoped, not global — pick something that kills naming collisions.
- Design tokens — a single source of truth for color/spacing/type.
- A component library / design system — styles live with reusable components.
- Purging / tree-shaking — dead CSS must be removable.
- A linter (Stylelint) and conventions.
Senior framing
The senior answer refuses the false dichotomy. It names the actual problems (scope, specificity, dead code, theming, consistency), maps solutions to constraints (SSR/RSC, team size, dynamism, perf budget), and notes the current consensus: zero-runtime scoping (CSS Modules / vanilla-extract / Tailwind) + CSS custom properties for tokens, with runtime CSS-in-JS losing ground due to the server-components shift. "It depends — here's on what" beats "X is best."
Follow-up questions
- •Why has runtime CSS-in-JS lost favor with React Server Components?
- •How do CSS custom properties complement any styling approach?
- •What's the difference between runtime and zero-runtime CSS-in-JS?
Common mistakes
- •Claiming one approach is universally best.
- •Ignoring the SSR/RSC implications of runtime CSS-in-JS.
- •No design-token / theming strategy.
- •No purging — CSS grows unbounded.
Edge cases
- •Migrating styling strategy mid-project is costly — choose deliberately.
- •Mixing approaches (CSS Modules + Tailwind) is common but needs conventions.
- •Runtime CSS-in-JS can cause hydration/style-flash issues if SSR isn't set up right.
Real-world examples
- •Design systems, multi-team apps, white-label/themed products.