NextJS DOC
Part 10 of 151. Next.js Project Structure: A Comprehensive Architecture Guide2. Next.js Layouts and Pages: Complete Architecture Guide3. Next.js Linking and Navigation: Complete Architecture Guide4. Next.js Server and Client Components: Complete Architecture Guide5. Next.js Data Fetching: Complete Architecture Guide6. Next.js Data Mutation: Complete Server Actions Guide7. Next.js Caching Deep Dive: Cache Components and the `use cache` Directive8. Next.js Revalidation Deep Dive: Time-Based and On-Demand Cache Invalidation9. Next.js Error Handling Deep Dive: Expected Errors, Uncaught Exceptions, and Recovery Patterns10. Next.js CSS Deep Dive: Tailwind, CSS Modules, Sass, and CSS-in-JS11. Next.js Image Optimization Deep Dive: Performance, Responsive Images, and Configuration12. Next.js Font Optimization Deep Dive: Self-Hosted Fonts, CSS Variables, and CLS Prevention13. Next.js Metadata and OG Images Deep Dive: SEO, Social Sharing, and Dynamic Generation14. Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards15. Next.js Proxy Deep Dive: Edge-First Request Interception
Next.js CSS Deep Dive: Tailwind, CSS Modules, Sass, and CSS-in-JS
Vidhya Sagar ThakurApril 1, 202691 min read0 views
Next.js CSS Deep Dive: Tailwind, CSS Modules, Sass, and CSS-in-JS
Introduction
Next.js provides multiple approaches to styling applications, each with distinct tradeoffs for performance, maintainability, and developer experience. This guide covers the internals of each styling approach, CSS ordering behavior, and production patterns for building well-styled Next.js applications.
Styling Architecture Overview
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS CSS STYLING OPTIONS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ COMPILE-TIME CSS │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Tailwind CSS │ │ CSS Modules │ │ Global CSS │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Utility-first│ │ Scoped styles│ │ Application-wide │ │ │
│ │ │ PostCSS │ │ Hash-based │ │ Resets, typography │ │ │
│ │ │ Best perf │ │ class names │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Sass │ │ PostCSS │ │ │
│ │ │ │ │ Plugins │ │ │
│ │ │ Variables, │ │ │ │ │
│ │ │ mixins, │ │ Autoprefixer │ │ │
│ │ │ nesting │ │ Custom │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ RUNTIME CSS (CSS-in-JS) │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │styled-components│ │ styled-jsx │ │ emotion │ │ │
│ │ │ │ │ │ │ (WIP) │ │ │
│ │ │ Template │ │ Scoped styles │ │ │ │ │
│ │ │ literals │ │ built-in │ │ │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
│ │ │ │
│ │ REQUIREMENT: Style registry + useServerInsertedHTML │ │
│ │ (Client Components only) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ RECOMMENDATION HIERARCHY: │
│ 1. Tailwind CSS (best performance, most flexible) │
│ 2. CSS Modules (scoped, no runtime overhead) │
│ 3. Sass (if you need variables/mixins) │
│ 4. CSS-in-JS (if migrating existing codebase) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Tailwind CSS
Setup (Tailwind v4)
Bash
# Install dependencies
pnpm add -D tailwindcss @tailwindcss/postcss
JavaScript
// postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
CSS
/* app/globals.css */
@import 'tailwindcss';
TypeScript
// app/layout.tsx
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Tailwind v4 Features
Tailwind v4 uses a CSS-first configuration approach:
CSS
/* app/globals.css */
@import 'tailwindcss';
/* Custom theme values using CSS */
@theme {
--color-brand: #3b82f6;
--color-brand-dark: #1d4ed8;
--font-display: 'Inter', sans-serif;
--breakpoint-3xl: 1920px;
}
/* Plugin imports */
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
Production Patterns with Tailwind
TypeScript
// components/Button.tsx
import { cn } from '@/lib/utils'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
const variants = {
primary: 'bg-brand text-white hover:bg-brand-dark focus:ring-brand',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent hover:bg-gray-100 focus:ring-gray-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
export function Button({
variant = 'primary',
size = 'md',
className,
...props
}: ButtonProps) {
return (
<button
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
// Variant and size
variants[variant],
sizes[size],
// Custom overrides
className
)}
{...props}
/>
)
}
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Conditional and Dynamic Classes
TypeScript
// components/Card.tsx
interface CardProps {
children: React.ReactNode
selected?: boolean
status?: 'success' | 'warning' | 'error'
}
export function Card({ children, selected, status }: CardProps) {
return (
<div
className={cn(
'rounded-lg border p-4 transition-all',
// Conditional classes
selected && 'ring-2 ring-brand border-brand',
// Dynamic classes based on status
{
'border-green-500 bg-green-50': status === 'success',
'border-yellow-500 bg-yellow-50': status === 'warning',
'border-red-500 bg-red-50': status === 'error',
}
)}
>
{children}
</div>
)
}
CSS Modules
Basic Usage
CSS Modules generate unique class names, providing automatic scoping without runtime overhead.
CSS
/* components/Dashboard.module.css */
.container {
display: grid;
grid-template-columns: 250px 1fr;
gap: 1.5rem;
min-height: 100vh;
}
.sidebar {
background: var(--color-gray-50);
border-right: 1px solid var(--color-gray-200);
padding: 1.5rem;
}
.content {
padding: 1.5rem;
}
/* Composing classes */
.header {
composes: flex items-center justify-between from global;
margin-bottom: 1.5rem;
}
/* Nested selectors */
.card {
background: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Media queries */
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
}
TypeScript
// components/Dashboard.tsx
import styles from './Dashboard.module.css'
export function Dashboard() {
return (
<div className={styles.container}>
<aside className={styles.sidebar}>
<nav>{/* Navigation */}</nav>
</aside>
<main className={styles.content}>
<header className={styles.header}>
<h1>Dashboard</h1>
</header>
<div className={styles.card}>
{/* Content */}
</div>
</main>
</div>
)
}
TypeScript Support for CSS Modules
TypeScript
// types/css-modules.d.ts
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string }
export default classes
}
For typed CSS Modules with autocomplete:
Bash
# Install typed-css-modules
pnpm add -D typed-css-modules
# Generate types
tcm src/
Combining CSS Modules with Tailwind
CSS
/* components/Button.module.css */
.button {
@apply inline-flex items-center justify-center rounded-md font-medium;
@apply transition-colors duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.primary {
@apply bg-blue-600 text-white hover:bg-blue-700;
}
.secondary {
@apply bg-gray-100 text-gray-900 hover:bg-gray-200;
}
/* Custom styles not in Tailwind */
.button::after {
content: '';
position: absolute;
inset: -2px;
border-radius: inherit;
pointer-events: none;
}
TypeScript
// components/Button.tsx
import styles from './Button.module.css'
import { cn } from '@/lib/utils'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
}
export function Button({ variant = 'primary', className, ...props }: ButtonProps) {
return (
<button
className={cn(
styles.button,
styles[variant],
className
)}
{...props}
/>
)
}
Global CSS
Structure
CSS
/* app/globals.css */
/* CSS Custom Properties (Design Tokens) */
:root {
--color-primary: #3b82f6;
--color-primary-dark: #1d4ed8;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-900: #111827;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
/* Dark mode variables */
[data-theme='dark'] {
--color-primary: #60a5fa;
--color-gray-50: #18181b;
--color-gray-100: #27272a;
--color-gray-200: #3f3f46;
--color-gray-900: #fafafa;
}
/* CSS Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
background: var(--color-gray-50);
color: var(--color-gray-900);
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2.25rem; }
h2 { font-size: 1.875rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
p {
margin-bottom: 1rem;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Utility classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Import in Root Layout
TypeScript
// app/layout.tsx
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Sass Integration
Setup
Bash
pnpm add -D sass
Configuration
TypeScript
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
sassOptions: {
// Prepend content to every Sass file
additionalData: `
@use 'styles/variables' as *;
@use 'styles/mixins' as *;
`,
// Use faster Sass implementation
implementation: 'sass-embedded',
},
}
export default nextConfig
Sass Variables and Mixins
scss
// styles/_variables.scss
$color-primary: #3b82f6;
$color-primary-dark: #1d4ed8;
$color-gray-50: #f9fafb;
$color-gray-900: #111827;
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$font-sans: 'Inter', system-ui, sans-serif;
$font-mono: 'JetBrains Mono', monospace;
$spacing-unit: 0.25rem;
// styles/_mixins.scss
@mixin responsive($breakpoint) {
@media (min-width: $breakpoint) {
@content;
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin truncate($lines: 1) {
@if $lines == 1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
@mixin button-variant($bg, $text, $hover-bg) {
background-color: $bg;
color: $text;
&:hover:not(:disabled) {
background-color: $hover-bg;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
Using Sass with CSS Modules
scss
// components/Card.module.scss
@use 'styles/variables' as *;
@use 'styles/mixins' as *;
.card {
background: white;
border-radius: 0.5rem;
padding: $spacing-unit * 4;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@include responsive($breakpoint-md) {
padding: $spacing-unit * 6;
}
}
.title {
@include truncate(2);
font-size: 1.25rem;
font-weight: 600;
color: $color-gray-900;
margin-bottom: $spacing-unit * 2;
}
.content {
color: lighten($color-gray-900, 30%);
line-height: 1.6;
}
Exporting Sass Variables to JavaScript
scss
// styles/variables.module.scss
$primary-color: #3b82f6;
$secondary-color: #6366f1;
$breakpoint-md: 768px;
:export {
primaryColor: $primary-color;
secondaryColor: $secondary-color;
breakpointMd: $breakpoint-md;
}
TypeScript
// components/DynamicStyles.tsx
import variables from '@/styles/variables.module.scss'
export function DynamicStyles() {
// Use Sass variables in JavaScript
return (
<div style={{ color: variables.primaryColor }}>
Styled with Sass variable
</div>
)
}
CSS-in-JS
styled-components Setup
Bash
pnpm add styled-components
pnpm add -D @types/styled-components
JavaScript
// next.config.js
module.exports = {
compiler: {
styledComponents: true,
},
}
TypeScript
// lib/registry.tsx
'use client'
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
// Only create stylesheet once with lazy initial state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement()
styledComponentsStyleSheet.instance.clearTag()
return <>{styles}</>
})
if (typeof window !== 'undefined') return <>{children}</>
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
)
}
TypeScript
// app/layout.tsx
import StyledComponentsRegistry from './lib/registry'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
)
}
styled-components Usage
TypeScript
// components/StyledButton.tsx
'use client'
import styled, { css } from 'styled-components'
interface ButtonProps {
$variant?: 'primary' | 'secondary'
$size?: 'sm' | 'md' | 'lg'
}
const sizes = {
sm: css`
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
`,
md: css`
padding: 0.5rem 1rem;
font-size: 1rem;
`,
lg: css`
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
`,
}
const variants = {
primary: css`
background-color: #3b82f6;
color: white;
&:hover:not(:disabled) {
background-color: #2563eb;
}
`,
secondary: css`
background-color: #e5e7eb;
color: #1f2937;
&:hover:not(:disabled) {
background-color: #d1d5db;
}
`,
}
export const Button = styled.button<ButtonProps>`
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
${({ $size = 'md' }) => sizes[$size]}
${({ $variant = 'primary' }) => variants[$variant]}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
`
styled-jsx Setup
TypeScript
// lib/styled-jsx-registry.tsx
'use client'
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { StyleRegistry, createStyleRegistry } from 'styled-jsx'
export default function StyledJsxRegistry({
children,
}: {
children: React.ReactNode
}) {
const [jsxStyleRegistry] = useState(() => createStyleRegistry())
useServerInsertedHTML(() => {
const styles = jsxStyleRegistry.styles()
jsxStyleRegistry.flush()
return <>{styles}</>
})
return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>
}
styled-jsx Usage
TypeScript
// components/Card.tsx
'use client'
export function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="card">
<h2 className="title">{title}</h2>
<div className="content">{children}</div>
<style jsx>{`
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.content {
color: #4b5563;
}
@media (max-width: 768px) {
.card {
padding: 1rem;
}
}
`}</style>
</div>
)
}
CSS Ordering and Chunking
Understanding CSS Order
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ CSS ORDERING RULES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Import order determines CSS cascade order │
│ │
│ // page.tsx │
│ import { BaseButton } from './base-button' // CSS from this loads FIRST │
│ import styles from './page.module.css' // CSS from this loads SECOND│
│ │
│ RESULT: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 1. base-button.module.css (higher specificity wins on equal weight) │ │
│ │ 2. page.module.css │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ DEVELOPMENT vs PRODUCTION: │
│ ┌────────────────────────────┬────────────────────────────────────────┐ │
│ │ Development │ Production │ │
│ ├────────────────────────────┼────────────────────────────────────────┤ │
│ │ Hot Module Replacement │ Concatenated + minified │ │
│ │ Instant updates │ Code-split .css files │ │
│ │ May differ from prod │ Final CSS order │ │
│ │ │ │ │
│ │ ALWAYS verify in `build` │ This is what users see │ │
│ └────────────────────────────┴────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
CSS Chunking Configuration
TypeScript
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Control CSS chunking behavior
cssChunking: 'loose', // 'strict' | 'loose'
// 'strict': More aggressive chunking, smaller per-route CSS
// 'loose': Larger chunks, potentially fewer network requests
}
export default nextConfig
Best Practices for Predictable CSS Order
TypeScript
// ✓ GOOD: Single entry point for imports
// app/layout.tsx
import '@/styles/globals.css' // Global styles first
import '@/styles/typography.css' // Then typography
import '@/styles/components.css' // Then component base styles
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
// ✓ GOOD: Consistent import order in components
// components/Button.tsx
import styles from './Button.module.css' // Component styles
import { cn } from '@/lib/utils' // Utilities after styles
// ✗ BAD: Auto-sorted imports that change CSS order
// ESLint sort-imports or Prettier can reorder these
import styles from './page.module.css'
import { Button } from './Button' // Button's CSS now loads after page!
Disable Import Sorting for CSS
JavaScript
// .eslintrc.js
module.exports = {
rules: {
'sort-imports': 'off',
'import/order': ['error', {
groups: [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
'object',
'type',
],
// Keep CSS imports in their original order
pathGroups: [
{
pattern: '*.css',
group: 'index',
position: 'after',
},
],
}],
},
}
External Stylesheets
Importing from node_modules
TypeScript
// app/layout.tsx
import 'bootstrap/dist/css/bootstrap.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/600.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
React 19 Link Component
TypeScript
// app/layout.tsx
import { link } from 'react-dom/components'
export default function RootLayout({ children }) {
return (
<html>
<head>
<link
rel="stylesheet"
href="https://cdn.example.com/styles.css"
precedence="default"
/>
</head>
<body>{children}</body>
</html>
)
}
Complete Application Structure
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ RECOMMENDED CSS STRUCTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ app/ │
│ ├── globals.css ← Global styles, Tailwind import │
│ ├── layout.tsx ← Imports globals.css │
│ │ │
│ ├── (marketing)/ │
│ │ ├── marketing.css ← Route-group specific styles │
│ │ └── layout.tsx ← Imports marketing.css │
│ │ │
│ └── dashboard/ │
│ ├── dashboard.css ← Dashboard-specific styles │
│ └── layout.tsx ← Imports dashboard.css │
│ │
│ components/ │
│ ├── ui/ │
│ │ ├── Button/ │
│ │ │ ├── Button.tsx │
│ │ │ ├── Button.module.css ← Scoped component styles │
│ │ │ └── index.ts │
│ │ └── Card/ │
│ │ ├── Card.tsx │
│ │ ├── Card.module.css │
│ │ └── index.ts │
│ │ │
│ └── features/ │
│ └── Dashboard/ │
│ ├── Dashboard.tsx │
│ ├── Dashboard.module.css │
│ └── index.ts │
│ │
│ styles/ │
│ ├── _variables.scss ← Sass variables (if using Sass) │
│ ├── _mixins.scss ← Sass mixins │
│ └── variables.module.scss ← Exported Sass variables for JS │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Styling Decision Matrix
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ STYLING DECISION MATRIX │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SCENARIO RECOMMENDATION │
│ ───────────────────────────────────────────────────────────────────────── │
│ New project, performance critical Tailwind CSS │
│ Need design system utilities Tailwind CSS │
│ Component library CSS Modules + Tailwind │
│ Complex animations CSS Modules + Tailwind │
│ Theming support CSS Variables + Tailwind │
│ Design tokens from Figma CSS Variables + Tailwind │
│ Legacy codebase migration Keep existing + add Tailwind │
│ Server Components styling Tailwind or CSS Modules │
│ Client Components with dynamic CSS-in-JS or Tailwind │
│ Third-party CSS frameworks Import in layout │
│ │
│ PERFORMANCE RANKING (Best to Worst): │
│ 1. Tailwind CSS (purged, minimal CSS) │
│ 2. CSS Modules (scoped, no runtime) │
│ 3. Global CSS (simple, but can grow) │
│ 4. Sass (adds compile step) │
│ 5. CSS-in-JS (runtime overhead, SSR complexity) │
│ │
│ BUNDLE SIZE IMPACT: │
│ ┌──────────────────────┬───────────────────────────────────────────────┐ │
│ │ Approach │ Impact │ │
│ ├──────────────────────┼───────────────────────────────────────────────┤ │
│ │ Tailwind │ ~10KB (purged, compressed) │ │
│ │ CSS Modules │ Minimal (only used classes) │ │
│ │ Global CSS │ Varies (all styles loaded) │ │
│ │ styled-components │ ~15KB runtime + styles │ │
│ │ emotion │ ~11KB runtime + styles │ │
│ └──────────────────────┴───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Takeaways
- Tailwind First: Use Tailwind CSS as the primary styling approach for best performance and DX
- CSS Modules for Scoping: Use CSS Modules when you need custom CSS with automatic scoping
- Global CSS Sparingly: Limit global CSS to truly global styles (resets, typography, CSS variables)
- Sass for Variables/Mixins: Use Sass when you need variables, mixins, or nesting beyond CSS capabilities
- CSS-in-JS Carefully: Only use CSS-in-JS if migrating existing codebase or need runtime styling
- Verify in Production: CSS order can differ between dev and build - always verify final build
- Disable Auto-Sort: Turn off ESLint/Prettier import sorting for CSS to maintain order
- Use CSS Variables: Prefer CSS custom properties for theming over Sass variables
- Co-locate Styles: Keep component CSS modules next to component files
- Single Entry Point: Import global styles in root layout for predictable ordering
References
What did you think?