Home JavaScript Tailwind in Next.js 16 — Wrong content Config Generated a 14MB CSS File
Beginner 5 min · July 12, 2026

Tailwind in Next.js 16 — Wrong content Config Generated a 14MB CSS File

Tailwind's content paths determine CSS file size.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 20 min
  • Next.js app directory basics
  • CSS fundamentals
  • Node.js 18+ installed
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Tailwind's content array tells the compiler which files to scan for class names — missing a path means classes aren't purged, and the CSS file balloons to 14MB+
  • CSS Modules in Next.js are colocated with components, auto-scoped, and zero-runtime — the most performant styling approach for app router
  • Global CSS imports are only allowed in root layouts — importing global CSS in a component throws a build error in app router
  • CSS-in-JS libraries (styled-components, Emotion) require client-side JavaScript to inject styles — they add 12-15KB to the bundle and cause a hydration flash on first paint
  • PostCSS config in Next.js is how Tailwind and other CSS transform tools are configured — a misconfigured PostCSS plugin chain can silently break production styles
✦ Definition~90s read
What is Styling in Next.js 16?

Styling in Next.js 16 supports multiple approaches, each with distinct trade-offs for Server Components. Tailwind CSS is the most popular — a utility-first framework that generates CSS at build time with zero runtime cost. Its content configuration defines which files to scan for class names; misconfiguration can produce CSS files ranging from 50KB to 14MB.

Tailwind's content config is like telling a librarian which sections of the library to search for overdue books.

CSS Modules provide colocated, auto-scoped styling that works in both Server and Client Components without runtime overhead. Global CSS is restricted to root layouts in the app router, enforcing a single global stylesheet per route tree. CSS-in-JS libraries (styled-components, Emotion) add runtime JavaScript to inject styles, requiring 'use client' and causing a flash of unstyled content on first paint.

The PostCSS pipeline processes all CSS in Next.js. The default configuration runs postcss-import (resolves @import), tailwindcss (generates utility classes), and autoprefixer (adds vendor prefixes). Plugin order is critical — running autoprefixer before tailwindcss means the prefixer has no classes to prefix.

Custom plugins like postcss-nesting and cssnano must be placed in the correct position relative to the defaults.

Tailwind's static analysis generates only the CSS classes it finds in your source files. This requires complete class name strings — template literals like text-${color}-500 cannot be analyzed and must use object maps or safelist entries. The clsx and tailwind-merge libraries manage conditional classes and resolve conflicts between overlapping Tailwind utilities.

Component libraries like shadcn/ui integrate by copying component source code into the project for full ownership, while daisyUI works as a Tailwind plugin with pre-built component classes.

Theming uses CSS custom properties mapped to Tailwind color names in the config. Define theme colors as RGB values in CSS :root and .dark selectors, reference them via rgb(var(--color) / <alpha-value>) in the Tailwind config, and use semantic class names (bg-primary, text-secondary) in components.

This enables dynamic theming with zero runtime cost — change the CSS variables, and the entire UI re-themes without component code changes.

Plain-English First

Tailwind's content config is like telling a librarian which sections of the library to search for overdue books. If you tell them to search the entire library, they check every single book — slow but thorough. If you miss a section, you get no results from that section. With Tailwind, missing a path means it doesn't scan those files for class names, so those classes don't make it into the CSS output. But if your content glob is too broad (like scanning node_modules), Tailwind finds millions of potential class names and can't purge anything effectively, resulting in a massive CSS file.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

Tailwind CSS is the dominant styling approach in the Next.js ecosystem, and for good reason: utility-first classes, excellent purging, and zero-runtime cost. But the content configuration — which tells Tailwind where to look for class names — is a single point of failure. A wrong glob pattern, a missing directory, or an overbroad path can produce a CSS file that's either missing styles (missing content path) or unreasonably large (overbroad or missing path that includes too many files).

Next.js supports multiple styling approaches: Tailwind CSS (most popular), CSS Modules (colocated, scoped, zero-runtime), Global CSS (applied site-wide, limited to root layout), and CSS-in-JS (runtime styling with dynamic capabilities). Each has trade-offs in performance, developer experience, and compatibility with Server Components. The wrong choice can add 15KB to your client bundle, cause hydration mismatches, or break Server Component rendering.

In this article, you'll learn the exact Tailwind content configuration that produces minimal CSS, how CSS Modules work in the app router, why CSS-in-JS is problematic with Server Components, and the PostCSS configuration that ties it all together. You'll get production patterns for styling in Next.js 16 that avoid the 14MB CSS file disaster.

Tailwind content Config — The Single Point of Failure for CSS Size

The content array in tailwind.config.ts defines which files Tailwind scans for class names. It's the most important configuration value because it directly determines the CSS output size and which classes are available.

Correct config: list every directory that contains Tailwind class usage. Standard setup: ['./app/*/.{ts,tsx}', './components/*/.{ts,tsx}', './lib/*/.{ts}']. Each path is a glob pattern that matches the files in that directory.

Common mistakes: using './*/.{ts,tsx}' (scans node_modules — millions of files), forgetting to add new directories ('src/', 'features/'), and not including non-component files that construct class names (utility functions, constants).

The result of a wrong content config: either a 10MB+ CSS file (overbroad scan that includes node_modules, generating every possible class combination) or missing styles (content doesn't cover the actual component files, so their classes are purged).

tailwind.config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// ============================================
// CORRECT content configuration
// ============================================

import type { Config } from 'tailwindcss'

const config: Config = {
  // EXACT source directories — never use './**/*'
  content: [
    './app/**/*.{ts,tsx}',       // App router pages
    './components/**/*.{ts,tsx}', // Shared components
    './lib/**/*.{ts}',            // Utility functions with class names
    './src/**/*.{ts,tsx}',        // Any additional source directories
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

export default config

// ============================================
// WRONG — overbroad, scans node_modules
// ============================================

// Wrong: content: ['./**/*.{ts,tsx}']
// This matches:
//   - app/page.tsx ✓
//   - components/button.tsx ✓
//   - node_modules/package/node_modules/**/*.tsx ✗ — 500K+ extra files
//   - .next/build-manifest.json ✗
//
// Result: Tailwind finds millions of potential class combos
// CSS output: 14MB instead of 80KB

// ============================================
// Safelist for dynamic class names
// ============================================

const configWithSafelist: Config = {
  content: ['./app/**/*.{tsx}', './components/**/*.{tsx}'],
  safelist: [
    // Exact classes to always include
    'bg-red-500',
    'bg-green-500',
    'bg-blue-500',
    // Patterns: include all variants
    {
      pattern: /^bg-(red|green|blue)-(100|200|300|400|500)$/,
    },
    {
      pattern: /^text-(sm|md|lg|xl)$/,
    },
  ],
}

// ============================================
// CI check for CSS size
// ============================================

// Add to package.json or CI script:
// Check that the built CSS file is under 500KB
//
// #!/bin/bash
// CSS_SIZE=$(du -k .next/static/css/*.css | awk '{print $1}')
// if [ "$CSS_SIZE" -gt "500" ]; then
//   echo "FAIL: CSS file is ${CSS_SIZE}KB (limit: 500KB)"
//   exit 1
// fi
// echo "PASS: CSS file is ${CSS_SIZE}KB"
Try it live
Never Use './**/*' as Your Content Glob
The glob './*/.{ts,tsx}' matches files in node_modules, .next, and every other directory. Tailwind generates CSS for every class combination found in ALL matched files. A typical Next.js project with this config produces a 10-15MB CSS file. Always specify exact directories: ['./app/*/.{tsx}', './components/*/.{tsx}'].
Production Insight
After the 14MB CSS incident, the team added a CI check that measures the CSS output size after every build. A CI step runs: du -k .next/static/css/*.css and fails if any CSS file exceeds 500KB. Three weeks later, the check caught a developer who accidentally reintroduced the broad glob pattern — the CI failed immediately, and the team fixed it before the change reached production.
Rule: add CSS size monitoring to your CI pipeline. A sudden CSS size spike is almost always a content config issue. Catch it before it reaches production.
Key Takeaway
content array must list exact source directories — never use './*/'.
Missing a directory in content = those components' classes are purged.
Add safelist entries for dynamically constructed class names.
CI-check CSS output size to catch content config regressions.
nextjs-styling-tailwind-css-modules THECODEFORGE.IO Next.js 16 Styling Architecture Layers How Tailwind, CSS Modules, and CSS-in-JS coexist Server Components Dynamic classes via clsx | CSS variables for theming Client Components CSS Modules (colocated) | CSS-in-JS (runtime cost) Global Styles Root layout only | Tailwind base layer Build Pipeline PostCSS with Tailwind plugin | Class merging via clsx THECODEFORGE.IO
thecodeforge.io
Nextjs Styling Tailwind Css Modules

CSS Modules — Colocated, Scoped, Zero-Runtime Styling for Server Components

CSS Modules are the most performant styling approach for Next.js Server Components. They're colocated with components (ComponentName.module.css next to ComponentName.tsx), auto-scoped to the component (no class name collisions), and have zero runtime cost (the CSS is extracted during build — no JavaScript injection).

Unlike global CSS — which can only be imported in root layouts — CSS Modules can be imported in any Server or Client Component. The import returns an object with scoped class names: import styles from './Component.module.css' then className={styles.container}. The build process generates unique class names to prevent collisions.

The trade-off: class names are accessed via object properties (styles.container), which is more verbose than Tailwind's className string. CSS Modules also don't compose as naturally with utility-first approaches — you end up with more custom CSS. Many teams use CSS Modules for complex layout components (containers, grids, sections) and Tailwind for utility classes (padding, colors, typography).

css-modules-pattern.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/* ============================================
   Component.module.css — colocated, scoped
   ============================================ */

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 1rem;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 0;
  border-bottom: 1px solid #e5e7eb;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 1.5rem;
  padding: 2rem 0;
}

@media (max-width: 768px) {
  .grid {
    grid-template-columns: 1fr;
  }
}

// ============================================
// Component.tsx — importing CSS Module
// ============================================

// This works in BOTH Server and Client Components
import styles from './Component.module.css'

interface ProductGridProps {
  children: React.ReactNode
}

export function ProductGrid({ children }: ProductGridProps) {
  // styles.container and styles.grid are auto-generated unique class names
  // e.g., 'Component_container__3xY7a' and 'Component_grid__9kL2m'
  return (
    <div className={styles.container}>
      <header className={styles.header}>
        <h2 className="text-2xl font-bold">Products</h2>
        {/* Tailwind utility classes still work alongside CSS Modules */}
      </header>
      <div className={styles.grid}>
        {children}
      </div>
    </div>
  )
}

// ============================================
// Multiple class names with CSS Modules
// ============================================

import styles from './Card.module.css'
import clsx from 'clsx'

interface CardProps {
  variant: 'default' | 'featured' | 'compact'
  children: React.ReactNode
}

export function Card({ variant, children }: CardProps) {
  return (
    <div
      className={clsx(
        styles.card,
        variant === 'featured' && styles.featured,
        variant === 'compact' && styles.compact
      )}
    >
      {children}
    </div>
  )
}
Try it live
CSS Modules = Scoped by Default, Zero Runtime
  • CSS Modules can be imported in any Server or Client Component — no restriction like global CSS
  • Class names are automatically scoped — .container in one module is unique from .container in another
  • Zero runtime cost — CSS is extracted during build, no JavaScript injection needed
  • Mix with Tailwind: use CSS Modules for complex layouts, Tailwind for utility classes
Production Insight
A team using only global CSS (imported in root layout) for all styling hit a build error when they added a second global CSS file in a page component. App router enforces the 'global CSS only in root layout' rule. They had to refactor all page-level CSS into CSS Modules — which turned out to be a better architecture anyway. Each page now owns its styles, and there are no global conflicts.
Rule: use CSS Modules for any component-level styling. Reserve global CSS for truly site-wide concerns (CSS reset, font faces, Tailwind directives). CSS Modules work everywhere in the app router and are the safest default.
Key Takeaway
CSS Modules are colocated, scoped, and zero-runtime — ideal for Server Components.
Import CSS Modules in any component — no root layout restriction.
Use CSS Modules for custom layout CSS, Tailwind for utility classes — both work together.

Global CSS — Only in Root Layouts and Why That Rule Exists

Next.js app router enforces a strict rule: global CSS files can only be imported in root layouts (app/layout.tsx). Importing a global CSS file in a page, component, or nested layout throws a build error: 'Global CSS cannot be imported from within node_modules or components.'

The reason: global CSS affects the entire page. If two different routes both imported global CSS that defined the same class, the styles would clash unpredictably. By limiting global CSS to the root layout, Next.js ensures a single, predictable global stylesheet. Component-level CSS must be CSS Modules (auto-scoped) or Tailwind (utility classes with no global scope).

The practical implication: import Tailwind directives (@tailwind base, @tailwind components, @tailwind utilities) only in your root layout's global CSS file. Import CSS resets and font-face declarations there too. Everything else should be CSS Modules or Tailwind utility classes in component files.

global-css-pattern.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/* ============================================
   app/globals.css — only imported in root layout
   ============================================ */

/* Tailwind directives — must be in a global CSS file */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* CSS reset — site-wide, applied once */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/* Font face declarations — site-wide */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-Variable.woff2') format('woff2');
  font-display: swap;
}

/* Custom properties — site-wide */
:root {
  --color-primary: #2563eb;
  --color-secondary: #7c3aed;
  --max-width: 1200px;
}

/* ============================================
   app/layout.tsx — only place for global CSS import
   ============================================ */

import './globals.css' // Only here!

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className="font-sans antialiased">{children}</body>
    </html>
  )
}

// ============================================
// WRONG — this will throw a build error
// ============================================

// app/dashboard/page.tsx
// import './dashboard.css' // ERROR: Global CSS cannot be imported from components

// Use CSS Modules instead:
// import styles from './dashboard.module.css'
Try it live
Global CSS Imports in Components = Build Error
App router throws a compilation error if you import a global .css file outside the root layout. This is intentional — it prevents global style conflicts between routes. Use CSS Modules (*.module.css) for component-level styles. If you need to share CSS variables or design tokens, use CSS custom properties in your root layout's global CSS.
Production Insight
A team migrating from pages router (which allowed global CSS anywhere) to app router hit this error on their first build. They had 12 global CSS files imported across different components. The migration required: (1) consolidate true global styles (resets, fonts, variables) into the root layout, (2) convert all component-level CSS to CSS Modules, (3) move page-level CSS to colocated .module.css files. The result was cleaner, more predictable styling.
Rule: during migration from pages router to app router, audit all global CSS imports. Move the essential ones to root layout. Convert everything else to CSS Modules. This is usually the biggest styling refactor in the migration.
Key Takeaway
Global CSS imports are only allowed in app/layout.tsx — anywhere else throws a build error.
Use globals.css for Tailwind directives, resets, fonts, and custom properties.
All component and page-level CSS must use CSS Modules (*.module.css).
Tailwind content Config: Narrow vs Broad Impact on CSS file size and build performance Narrow content config Broad content config File globs Only src/**/*.{js,ts,jsx,tsx} Includes node_modules, public, etc. CSS output size ~10KB 14MB Build time Fast (~2s) Slow (~30s) Class duplication Minimal High (many unused classes) Maintenance Easy to audit Hard to debug THECODEFORGE.IO
thecodeforge.io
Nextjs Styling Tailwind Css Modules

CSS-in-JS in the App Router — Runtime Cost and Hydration Flash

CSS-in-JS libraries (styled-components, Emotion, styled-jsx) inject styles at runtime via JavaScript. In the pages router, this was handled by extracting styles during SSR and injecting them into the HTML. In the app router with Server Components, CSS-in-JS faces fundamental challenges:

  1. Server Components cannot use CSS-in-JS — the 'styled' APIs require React context, which is client-only
  2. Client Components using CSS-in-JS must wait for JavaScript to load before styles are applied — causing a flash of unstyled content (FOUT)
  3. CSS-in-JS adds 12-15KB to the client bundle for the runtime library

The recommended approach: skip CSS-in-JS for new projects in the app router. Use Tailwind CSS or CSS Modules. If you must use CSS-in-JS (legacy project, design system requirements), wrap all styled components in Client Component boundaries and use next/dynamic with ssr: false to avoid SSR mismatches.

css-in-js-app-router.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ============================================
// CSS-in-JS in app router — limitations
// ============================================

// --- Server Component — CANNOT use CSS-in-JS ---

import { styled } from 'styled-components' // ERROR in Server Component

// styled-components uses React context, which requires 'use client'
// This component will throw at build time

// --- CORRECT: wrap in Client Component ---

'use client'

import styled from 'styled-components'

const StyledButton = styled.button`
  background-color: #2563eb;
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-weight: 600;

  &:hover {
    background-color: #1d4ed8;
  }
`

interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
}

export function Button({ children, onClick }: ButtonProps) {
  return <StyledButton onClick={onClick}>{children}</StyledButton>
}

// --- Avoid hydration flash with dynamic import ---

// If the styled component triggers a hydration mismatch (FOUT),
// use next/dynamic with ssr: false to prevent SSR

import dynamic from 'next/dynamic'

const DynamicButton = dynamic(
  () => import('./styled-button').then((mod) => mod.Button),
  { ssr: false } // Renders on client only — no SSR, no hydration mismatch
)

// ============================================
// Better approach: Tailwind + CSS Modules
// ============================================

// Zero runtime cost, works in Server Components, no FOUT
// The same button in Tailwind:

interface TailwindButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
}

export function TailwindButton({
  children,
  onClick,
  variant = 'primary',
}: TailwindButtonProps) {
  return (
    <button
      onClick={onClick}
      className={`
        rounded-md px-4 py-2 font-semibold transition-colors
        ${variant === 'primary'
          ? 'bg-blue-600 text-white hover:bg-blue-700'
          : 'bg-gray-200 text-gray-900 hover:bg-gray-300'
        }
      `}
    >
      {children}
    </button>
  )
}
Try it live
CSS-in-JS Adds 12-15KB and Causes FOUT in App Router
Styled-components and Emotion require client-side JavaScript to inject styles. This adds 12-15KB to your client bundle and causes a visible flash of unstyled content (FOUT) on first load because the HTML arrives without styles — they're injected after JavaScript hydration. Tailwind and CSS Modules have zero runtime cost and no FOUT.
Production Insight
A team migrating a 50,000-line styled-components codebase to the app router faced a significant challenge. Every styled-component file had to be marked 'use client', making all those components client-rendered. They used a codemod to automatically add 'use client' to files using styled-components, then gradually migrated component styles to Tailwind. Estimated bundle savings: 15KB (CSS-in-JS runtime) + 60KB (unnecessary client bundles from forced 'use client').
Rule: for new apps in app router, don't use CSS-in-JS. For existing apps migrating from pages router, use the 'use client' codemod for immediate compatibility while planning a gradual migration to Tailwind or CSS Modules.
Key Takeaway
CSS-in-JS requires 'use client' — it cannot be used in Server Components.
CSS-in-JS adds 12-15KB runtime to client bundle and causes FOUT on first paint.
Tailwind and CSS Modules have zero runtime cost — use them for new app router projects.

PostCSS Config — How Tailwind Actually Runs in the Build Pipeline

Next.js uses PostCSS under the hood to transform CSS. Tailwind is a PostCSS plugin. The postcss.config.js (or postcss.config.mjs) file defines the plugin pipeline that processes your CSS during build.

The default Next.js + Tailwind setup includes: postcss-import (resolves @import statements), tailwindcss (generates utility classes), and autoprefixer (adds vendor prefixes). The order matters: tailwindcss must run before autoprefixer so that Tailwind's generated classes get prefixed.

Common PostCSS issues: missing plugins (styles don't transform), wrong plugin order (Tailwind output doesn't get prefixed), or custom plugins that conflict with Tailwind's class generation. Most projects should use the default PostCSS config provided by create-next-app.

postcss.config.mjsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ============================================
// Default PostCSS Config (create-next-app)
// ============================================

/** @type {import('postcss-load-config').Config} */
const config = {
  plugins: {
    'postcss-import': {},     // Resolves @import in CSS files
    'tailwindcss': {},         // Generates Tailwind utility classes
    'autoprefixer': {},        // Adds vendor prefixes (-webkit-, -moz-)
  },
}

export default config

// ============================================
// Custom PostCSS — adding additional plugins
// ============================================

const configWithCustom = {
  plugins: {
    'postcss-import': {},
    'tailwindcss': {},
    'postcss-nesting': {},    // Adds native CSS nesting support
    'autoprefixer': {
      flexbox: 'no-2009',    // Skip old 2009 flexbox prefixes
      grid: 'autoplace',     // Enable grid autoplacement prefixes
    },
    // cssnano should run LAST — it minifies
    ...(process.env.NODE_ENV === 'production'
      ? { cssnano: { preset: 'default' } }
      : {}),
  },
}

// ============================================
// WRONG — plugin order matters!
// ============================================

const wrongConfig = {
  plugins: {
    'autoprefixer': {},       // WRONG order: autoprefixer runs first
    'tailwindcss': {},        // Tailwind runs second — generates classes AFTER prefixing
    'postcss-import': {},     // WRONG: @import resolution should be first
  },
}

// autoprefixer has nothing to prefix because Tailwind hasn't run yet
// postcss-import runs late — @import statements in CSS won't resolve correctly

// ============================================
// Checking if PostCSS is working
// ============================================

// Run build and check output:
// 1. In .next/static/css/, verify the CSS file contains expected classes
// 2. Check for vendor prefixed properties: -webkit-, -moz-
// 3. Verify no @import statements remain in output
//
// Command:
// grep -E '@import|-webkit-|-moz-' .next/static/css/*.css | head -10
Try it live
Plugin Order: Import → Tailwind → Nesting → Autoprefixer → Minify
The standard PostCSS pipeline for Next.js + Tailwind: postcss-import resolves imports first, tailwindcss generates classes second, postcss-nesting (if used) processes nested CSS third, autoprefixer adds vendor prefixes fourth, and cssnano minifies last. Running plugins out of order breaks the transformation pipeline.
Production Insight
A team added postcss-nesting to use native CSS nesting, but placed it AFTER autoprefixer in the config. The nested CSS wasn't processed because autoprefixer passed the un-nested CSS through unchanged. The team saw no nesting-related build errors — styles just silently didn't apply. Moving postcss-nesting before autoprefixer fixed the issue.
Rule: the PostCSS plugin order is critical. The standard order (import → tailwindcss → nesting → autoprefixer) should be your default. Only deviate when you understand the implications.
Key Takeaway
PostCSS processes CSS through a plugin pipeline — order matters.
Standard order: postcss-import → tailwindcss → autoprefixer.
Custom plugins (nesting, minification) must go in the correct position relative to defaults.

Tailwind Class Merging — Avoiding Conflicts with clsx and tailwind-merge

Tailwind classes can conflict. Applying 'text-sm' and 'text-lg' to the same element results in both being in the className string — the last one in the CSS wins, which is unpredictable. The clsx library handles conditional class joining, and tailwind-merge (twMerge) intelligently resolves Tailwind class conflicts by keeping the last conflicting class.

The pattern: use clsx for conditional class logic (condition ? 'bg-red-500' : 'bg-blue-500'), and wrap the result with twMerge to handle conflicts between base classes, variant classes, and additional classes passed from parent components. This is essential for component libraries where consumers pass className that might conflict with internal classes.

class-merging-pattern.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// ============================================
// Without twMerge — class conflicts
// ============================================

interface BadButtonProps {
  variant?: 'primary' | 'secondary'
  size?: 'sm' | 'md' | 'lg'
  className?: string
  children: React.ReactNode
}

function BadButton({ variant = 'primary', size = 'md', className, children }: BadButtonProps) {
  // Both 'px-4 py-2' AND user's 'px-2 py-1' are in the string
  // CSS order determines which wins — unpredictable
  return (
    <button
      className={`px-4 py-2 rounded ${variant === 'primary' ? 'bg-blue-600' : 'bg-gray-200'} ${className || ''}`}
    >
      {children}
    </button>
  )
}

// Usage: BadButton className="px-2 py-1"
// Result: className="px-4 py-2 rounded bg-blue-600 px-2 py-1"
// Both px sizes are applied — CSS cascade determines the winner

// ============================================
// With clsx + twMerge — conflict resolution
// ============================================

import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

interface ButtonProps {
  variant?: 'primary' | 'secondary'
  size?: 'sm' | 'md' | 'lg'
  className?: string
  children: React.ReactNode
}

function Button({ variant = 'primary', size = 'md', className, children }: ButtonProps) {
  return (
    <button
      className={twMerge(
        // Base classes
        'rounded font-semibold transition-colors focus:outline-none focus:ring-2',
        // Size classes
        clsx({
          'px-3 py-1.5 text-sm': size === 'sm',
          'px-4 py-2 text-base': size === 'md',
          'px-6 py-3 text-lg': size === 'lg',
        }),
        // Variant classes
        clsx({
          'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
          'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
        }),
        // Consumer classes — passed from parent
        className
      )}
    >
      {children}
    </button>
  )
}

// Usage: <Button size="lg" className="px-2 py-1">Click</Button>
// twMerge detects conflict between 'px-6 py-3' (lg) and 'px-2 py-1' (consumer)
// The consumer's 'px-2 py-1' wins — last conflicting class in the merge wins
// Result: className="rounded font-semibold ... px-2 py-1 text-lg bg-blue-600 ..."
Try it live
twMerge Resolves Tailwind Class Conflicts Predictably
Without twMerge, two conflicting Tailwind classes (text-sm vs text-lg) both appear in the className. CSS specificity and order determine the winner — unpredictable. twMerge detects conflicts and keeps only the last class. Use it in any component that accepts a className prop from consumers.
Production Insight
A team's design system component library didn't use twMerge. Consumers passed custom className props that conflicted with internal component styles. Sometimes blue buttons turned green (bg-blue-600 conflicted with bg-green-500), sizes were wrong (text-sm overridden by text-base), and padding was unpredictable. Adding twMerge to every component that accepted className resolved all conflicts predictably: consumer classes always won.
Rule: every component in a shared library or design system that accepts a className prop must use twMerge to merge that prop with internal classes. This gives consumers full control over styling while preserving sensible defaults.
Key Takeaway
Tailwind classes with the same CSS property conflict — both end up in className.
clsx handles conditional class logic; twMerge resolves Tailwind-specific conflicts.
Use twMerge in any component that accepts className from consumers.

Tailwind with Server Components — Dynamic Classes and the Static Analysis Constraint

Tailwind relies on static analysis to generate CSS. It scans your source files for complete class name strings and generates only those classes. This is why the content config is critical — Tailwind must find the classes in your files.

The constraint: dynamically constructed class names (template literals with variables) cannot be statically analyzed. className={text-${color}-500} — Tailwind doesn't know what color might be, so it can't generate text-red-500, text-blue-500, etc.

Solutions: (1) use complete class name strings with conditional logic (clsx or ternary), (2) map objects that enumerate all possible values, (3) add dynamic patterns to the safelist in tailwind.config.ts. Option 2 is the most common: define an object that maps variant names to complete class strings.

dynamic-classes-tailwind.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// ============================================
// WRONG — Tailwind can't analyze dynamic classes
// ============================================

interface BadAlertProps {
  // Tailwind sees 'className={`alert-${severity}`}' — can't resolve
  severity: 'info' | 'warning' | 'error' | 'success'
  message: string
}

function BadAlert({ severity, message }: BadAlertProps) {
  return (
    <div className={`p-4 rounded-md text-white alert-${severity}`}>
      {/* alert-info, alert-warning, etc. — NOT GENERATED by Tailwind */}
      {message}
    </div>
  )
}

// ============================================
// CORRECT — complete class names with conditional
// ============================================

interface AlertProps {
  severity: 'info' | 'warning' | 'error' | 'success'
  message: string
}

function Alert({ severity, message }: AlertProps) {
  // Full class names in each case — Tailwind can analyze these
  const severityStyles = {
    info: 'bg-blue-100 text-blue-900 border-blue-400',
    warning: 'bg-yellow-100 text-yellow-900 border-yellow-400',
    error: 'bg-red-100 text-red-900 border-red-400',
    success: 'bg-green-100 text-green-900 border-green-400',
  }

  return (
    <div
      className={`p-4 rounded-md border ${severityStyles[severity]}`}
      role="alert"
    >
      {message}
    </div>
  )
}

// ============================================
// Alternative: clsx for conditional classes
// ============================================

import clsx from 'clsx'

function AlertClsx({ severity, message }: AlertProps) {
  return (
    <div
      className={clsx(
        'p-4 rounded-md border',
        severity === 'info' && 'bg-blue-100 text-blue-900 border-blue-400',
        severity === 'warning' && 'bg-yellow-100 text-yellow-900 border-yellow-400',
        severity === 'error' && 'bg-red-100 text-red-900 border-red-400',
        severity === 'success' && 'bg-green-100 text-green-900 border-green-400'
      )}
      role="alert"
    >
      {message}
    </div>
  )
}

// ============================================
// Alternative: safelist in tailwind.config.ts
// ============================================

// tailwind.config.ts
const config = {
  content: ['./app/**/*.{tsx}', './components/**/*.{tsx}'],
  safelist: [
    // Pattern-based safelist for dynamic severity classes
    {
      pattern: /^bg-(blue|yellow|red|green)-(100|200|300|400|500)$/,
      variants: ['hover', 'focus', 'active'],
    },
    {
      pattern: /^text-(blue|yellow|red|green)-(900|800|700)$/,
    },
    {
      pattern: /^border-(blue|yellow|red|green)-(400|500)$/,
    },
  ],
}
Try it live
Template Literal Class Names Are NOT Generated by Tailwind
Tailwind scans source files for complete class name strings. text-${color}-500 is 'text-', not 'text-red-500'. The class won't be generated. Always use complete class names with conditional logic, or safelist patterns for truly dynamic combinations. This is the most common Tailwind 'bug' reported by new users.
Production Insight
A team's notification component used template literals to construct severity classes: `bg-${severity}-100 text-${severity}-900`. In development, the classes worked because Tailwind's JIT mode scanned the full component and found other instances of 'bg-red-100', 'bg-blue-100', etc. But in production builds with more aggressive purging, the dynamic classes were stripped. One in every 50 notifications had no background color — the severity styles silently disappeared.
Rule: never use template literals to construct Tailwind class names. Use object maps with complete class strings. If you must use dynamic values, add the patterns to the safelist. Test production builds to ensure all dynamic class combinations are generated.
Key Takeaway
Tailwind generates only classes it can statically analyze — template literals are invisible to the scanner.
Use object maps or clsx with complete class name strings for variant-based styling.
Safelist patterns for truly dynamic class combinations that can't be enumerated.

Theming with Tailwind — CSS Variables and dark: Variants

Tailwind's dark mode and theming work through CSS custom properties (variables) and the dark: variant. Define your theme colors as CSS custom properties in :root (and .dark or @media prefers-color-scheme), then reference them in your Tailwind config's extend.colors.

The pattern: define semantic color tokens in CSS (--color-primary, --color-bg), map them to Tailwind color names in tailwind.config.ts, and use standard Tailwind classes (bg-primary, text-primary) in components. To switch themes, toggle a class on the root element (e.g., 'dark') or use the system preference media query.

This is more maintainable than hardcoding hex values in components — change the CSS variables, and the entire theme updates. It also enables user-customizable themes without changing component code.

theming-tailwind.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/* ============================================
   app/globals.css — theme definition
   ============================================ */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* Light theme */
    --color-primary: 37 99 235;      /* blue-600 */
    --color-primary-foreground: 255 255 255;
    --color-secondary: 124 58 237;   /* violet-600 */
    --color-bg: 255 255 255;
    --color-bg-secondary: 249 250 251;
    --color-text: 17 24 39;
    --color-text-secondary: 107 114 128;
    --color-border: 229 231 235;
    --color-success: 22 163 74;
    --color-warning: 234 179 8;
    --color-error: 220 38 38;
  }

  .dark {
    /* Dark theme */
    --color-primary: 96 165 250;     /* blue-400 */
    --color-primary-foreground: 0 0 0;
    --color-secondary: 167 139 250;  /* violet-400 */
    --color-bg: 17 24 39;
    --color-bg-secondary: 31 41 55;
    --color-text: 243 244 246;
    --color-text-secondary: 156 163 175;
    --color-border: 55 65 81;
    --color-success: 74 222 128;
    --color-warning: 250 204 21;
    --color-error: 248 113 113;
  }
}

/* ============================================
   tailwind.config.ts — variable mapping
   ============================================ */

const config = {
  content: ['./app/**/*.{tsx}', './components/**/*.{tsx}'],
  darkMode: 'class', // Toggle via className on <html>
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
          foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
        },
        secondary: {
          DEFAULT: 'rgb(var(--color-secondary) / <alpha-value>)',
        },
        bg: {
          DEFAULT: 'rgb(var(--color-bg) / <alpha-value>)',
          secondary: 'rgb(var(--color-bg-secondary) / <alpha-value>)',
        },
        text: {
          DEFAULT: 'rgb(var(--color-text) / <alpha-value>)',
          secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
        },
        border: 'rgb(var(--color-border) / <alpha-value>)',
        success: 'rgb(var(--color-success) / <alpha-value>)',
        warning: 'rgb(var(--color-warning) / <alpha-value>)',
        error: 'rgb(var(--color-error) / <alpha-value>)',
      },
    },
  },
}

// ============================================
// Usage in components
// ============================================

// No hardcoded hex values — all colors come from theme variables
// <div className="bg-bg text-text">
// <button className="bg-primary text-primary-foreground">
// <p className="text-text-secondary">
// <div className="dark:bg-bg-secondary">

// Toggle dark mode:
// document.documentElement.classList.toggle('dark')
Try it live
Use RGB Variables for Tailwind Opacity Support
Define CSS custom properties as space-separated RGB values (not hex) and reference them with rgb(var(--color) / <alpha-value>). This preserves Tailwind's opacity modifiers: bg-primary/50 becomes rgb(r g b / 0.5). Hex values in CSS variables break Tailwind's opacity system.
Production Insight
A SaaS app needed per-company branding — each customer got their own color scheme. They stored theme values in the database and injected them as CSS custom properties on the root element. The Tailwind config mapped --color-primary to bg-primary, text-primary, etc. No component code changes needed — just update the CSS variables, and the entire UI re-themes.
Rule: CSS custom properties + Tailwind's extend.colors is the most flexible theming approach. Component code uses semantic class names (bg-primary, text-secondary) — never hardcoded colors. Theming becomes a data change, not a code change.
Key Takeaway
Define theme colors as CSS custom properties with RGB values.
Map variables to Tailwind color names in tailwind.config.ts.
Use semantic class names in components — theming is a data change, not a code change.

Using third-party CSS frameworks (shadcn/ui, daisyUI) in Next.js

Component libraries like shadcn/ui (built on Tailwind) and daisyUI (Tailwind plugin) integrate with Tailwind's existing infrastructure. shadcn/ui is particularly well-suited for Next.js because it's not a dependency — it's a CLI that copies components into your project for full ownership and customization.

daisyUI adds component class names on top of Tailwind (btn, card, alert). It's a Tailwind plugin that generates additional utility classes. The trade-off: daisyUI adds ~20KB to the CSS output and has more opinionated styling that's harder to customize. shadcn/ui gives you the source code — every pixel is editable.

For both: ensure your content config includes the component library's files (shadcn/ui components live in components/ui/). For daisyUI, add it to the plugins array in tailwind.config.ts. For shadcn/ui, just use the generated components — no extra config needed.

shadcn-ui-setup.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ============================================
// shadcn/ui — installed via CLI, no npm dependency
// ============================================

// npx shadcn@latest init
// npx shadcn@latest add button card alert dialog

// Components are copied to components/ui/ — fully editable
// No runtime library, no CSS injection — just Tailwind classes

// --- components/ui/button.tsx (generated by shadcn/ui) ---

'use client'

import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
        outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2',
        sm: 'h-8 rounded-md px-3 text-xs',
        lg: 'h-10 rounded-md px-8',
        icon: 'h-9 w-9',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = 'Button'

// ============================================
// daisyUI — Tailwind plugin, adds component classes
// ============================================

// tailwind.config.ts
const config = {
  content: ['./app/**/*.{tsx}', './components/**/*.{tsx}'],
  plugins: [require('daisyui')], // Adds .btn, .card, .alert, etc.
  // daisyUI adds ~20KB to CSS output
}

// Usage: <button className="btn btn-primary">Click</button>
// <div className="card shadow-xl">...</div>
Try it live
shadcn/ui Gives Full Ownership — No Lock-In
shadcn/ui components are copied to your project as source files, not installed as npm dependencies. Every component is fully editable — change the Tailwind classes, modify the variants, or rewrite the component entirely. This avoids the 'fighting the framework' problem of other component libraries where customization requires overriding styles with ever-increasing specificity.
Production Insight
A team using daisyUI needed to customize the button component extensively — rounded corners, custom color variants, different hover states. daisyUI's CSS specificity made overrides difficult, requiring !important flags increasingly. They migrated to shadcn/ui where the button source code was in their project. They edited the 50-line button.tsx directly. No overrides, no specificity battles, no extra CSS.
Rule: for full control, use shadcn/ui. For rapid prototyping with less customization, daisyUI is faster. For design systems, always use shadcn/ui — component ownership means you control the output.
Key Takeaway
shadcn/ui copies components to your project — full ownership, no dependency lock-in.
daisyUI is a Tailwind plugin with pre-built component classes — faster setup, harder customization.
Ensure content config includes the component library's files to avoid purging their Tailwind classes.

Optimizing Tailwind's Production Build — CSS Size Benchmarks

A well-configured Tailwind production build produces a CSS file of 50-150KB for most Next.js applications. The size depends on: number of unique classes used, theme extensions (custom colors, spacing), plugins (daisyUI, typography), and safelist entries.

Optimization strategies: (1) narrow the content array to exact source directories, (2) minimize theme extensions — only add colors/sizes you actually use, (3) avoid adding entire Tailwind plugins for a handful of classes, (4) usesafelist patterns instead of listing individual classes, (5) set NODE_ENV=production — Tailwind enables purging automatically.

A bloated CSS file (>500KB) is almost always a content config issue. The fix: audit content paths, ensure node_modules isn't included, and verify that dynamic class names use proper patterns or safelist entries.

css-size-optimization.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ============================================
// Benchmark: CSS sizes for different setups
// ============================================

// Minimal Tailwind (content site, 10-20 components):
//   50-80 KB — good
//
// Medium Tailwind (dashboard, 50-100 components, custom theme):
//   100-150 KB — good
//
// Large Tailwind (complex app, multiple themes, plugins):
//   150-300 KB — acceptable, review if > 200KB
//
// Bloated (overbroad content, node_modules scanned):
//   2-15 MB — CRITICAL, fix content config immediately

// ============================================
// Measure CSS size in CI
// ============================================

// scripts/check-css-size.sh
// #!/bin/bash
// set -e
//
// CSS_FILE=$(ls .next/static/css/*.css 2>/dev/null | head -1)
// if [ -z "$CSS_FILE" ]; then
//   echo "No CSS file found in .next/static/css/"
//   exit 1
// }
//
// SIZE=$(du -k "$CSS_FILE" | awk '{print $1}')
// LIMIT=500
//
// if [ "$SIZE" -gt "$LIMIT" ]; then
//   echo "FAIL: CSS file is ${SIZE}KB (limit: ${LIMIT}KB)"
//   echo "Check tailwind.config.ts content array for overbroad globs"
//   exit 1
// fi
//
// echo "PASS: CSS file is ${SIZE}KB (limit: ${LIMIT}KB)"

// ============================================
// Analyze which classes are consuming the most space
// ============================================

// # Generate a human-readable breakdown of CSS output
// npx next build
//
// # Count unique class definitions in output
// grep -oE '\.([a-zA-Z0-9_-]+)' .next/static/css/*.css | sort | wc -l
//
// # Find largest rule blocks (possible inefficiencies)
// grep -oE '\{[^}]+\}' .next/static/css/*.css |
//   awk '{ print length() " " $0 }' |
//   sort -rn |
//   head -10
//
// # Check for duplicate class patterns
// grep -oE '\.(\w+)' .next/static/css/*.css |
//   sort |
//   uniq -c |
//   sort -rn |
//   head -20
Try it live
CSS Size = Content Scope × Unique Classes
  • Target CSS size: 50-150KB for most Next.js apps
  • CSS > 500KB is almost always a content config issue
  • Add CI check to fail builds with CSS > 500KB
  • Use safelist patterns, not individual class listings
Production Insight
The team that shipped the 14MB CSS file fixed their content config and got down to 89KB. The lesson was so impactful they added CSS size monitoring to their CI pipeline and their monitoring dashboard. The CI check has caught two regressions since — both caused by developers accidentally modifying the content array during refactoring.
Rule: CSS file size is a canary for Tailwind config health. Monitor it in CI and production. A sudden increase means something is wrong with your content paths.
Key Takeaway
Well-configured Tailwind produces 50-150KB CSS — 14MB means overbroad content glob.
Narrow content paths to exact directories to minimize CSS output.
CI-check CSS size to catch content config regressions before they reach production.
● Production incidentPOST-MORTEMseverity: high

14MB CSS File — Missing content Paths and Overbroad Globs

Symptom
The production CSS file was 14.2MB. Page load time increased by 8 seconds. The browser tab crashed on mobile devices. New components had no styles applied — they appeared as raw HTML. The team assumed it was a build configuration issue but couldn't identify the root cause.
Assumption
The team assumed Tailwind purged unused classes automatically based on the project's source code. They didn't understand that the content array explicitly defines which files to scan. When they added a new 'src/' directory for components, they forgot to add './src/*/.{ts,tsx}' to the content array. Tailwind didn't scan the new directory — all classes used in those components were treated as 'unused' and purged. Meanwhile, the existing content glob './*/.{ts,tsx}' was too broad and scanned node_modules, generating millions of potential class combinations.
Root cause
Two misconfigurations: (1) The content array was ['./*/.{ts,tsx}'] — the leading './**/' matched everything including node_modules (which contains thousands of .tsx files with Tailwind classes). Tailwind generated CSS for every possible class combination found across all scanned files. (2) The new 'src/' directory wasn't in the content array — components in 'src/' had their classes purged because Tailwind didn't scan them. Combined: a 14MB CSS file containing every class from node_modules plus none of the classes for the actual application components.
Fix
1) Changed content to ['./app/*/.{ts,tsx}', './components/*/.{ts,tsx}', './src/*/.{ts,tsx}'] — specific paths, no wildcard that could match node_modules. 2) Added ./src/*/.{ts,tsx} to cover the new components directory. 3) Set safelist for dynamically constructed class names that Tailwind can't statically analyze. 4) Added a CI check that fails if the CSS file exceeds 500KB. 5) Ran purgecss manually on the build output to verify the CSS was under 100KB. Final CSS: 89KB. Page load time dropped from 8s to 0.8s.
Key lesson
  • Tailwind's content array must explicitly list ALL directories containing Tailwind classes — missing a path means those classes are purged
  • Never use a broad glob like './*/.{ts,tsx}' — it scans node_modules and generates millions of possible class combinations
  • Always specify exact source directories: ['./app/*/.{tsx}', './components/*/.{tsx}', './lib/*/.{ts}']
  • Add a CI check that verifies the CSS output size — a sudden increase indicates a content config issue
  • Dynamically constructed class names must be safelisted — Tailwind can't statically analyze template literals
Production debug guideDiagnose CSS size, missing styles, and configuration issues in production5 entries
Symptom · 01
CSS file is unexpectedly large (2MB+)
Fix
Check the content array in tailwind.config.ts. A glob like './*/.{ts,tsx}' matches node_modules. Tailwind generates CSS for every class combination found in all scanned files. Narrow the content paths to specific source directories only.
Symptom · 02
New components have no Tailwind styles applied
Fix
Check if the component's directory is included in the content array. Tailwind only scans files matching content patterns. If you add an 'src/' or 'app/' subdirectory without updating content, classes in those files are purged as unused.
Symptom · 03
Dynamic class names (template literals) don't work
Fix
Tailwind scans for complete class names. className={text-${color}-500} cannot be statically analyzed. Use full class names or add the dynamic combinations to the safelist in tailwind.config.ts.
Symptom · 04
Global CSS import causes build error in app router
Fix
App router only allows global CSS imports in root layouts. If you import a global CSS file in a component or page, Next.js throws a build error. Move the import to app/layout.tsx or use CSS Modules for component-level styles.
Symptom · 05
CSS-in-JS styles flash on page load (FOUT/FOIT)
Fix
★ Styling Quick Debug ReferenceFast commands for diagnosing Tailwind and CSS issues in Next.js
Tailwind CSS too large (>1MB)
Immediate action
Check tailwind.config.ts content array for overbroad globs
Commands
cat tailwind.config.ts | grep -A 10 'content:'
find . -name '*.css' -exec ls -lh {} \; | grep 'main\|global\|styles'
Fix now
Change content from './*/.{ts,tsx}' to specific paths: ['./app/*/.{tsx}', './components/*/.{tsx}']
Tailwind classes not applying to new components+
Immediate action
Check if the component directory is in the content array
Commands
ls -d ./src ./app ./components 2>/dev/null
grep -oP "'[^']*'" tailwind.config.ts | grep -v 'node_modules'
Fix now
Add missing directory to content array: './src/*/.{tsx}'
Global CSS import build error+
Immediate action
Check if a non-root component imports a .css file
Commands
grep -rn "import.*\.css" app/ --include='*.tsx' --include='*.ts' | grep -v 'layout.tsx'
grep -rn "import.*\.css" components/ --include='*.tsx' --include='*.ts'
Fix now
Move global CSS imports to app/layout.tsx only. Use CSS Modules (*.module.css) for component-level styles.
Dynamic class name not working+
Immediate action
Check for template literal class names that Tailwind can't analyze
Commands
grep -rn 'className={`[^`]*\${[^`]*`}' app/ --include='*.tsx'
grep -rn "className=\".*\${.*\"" app/ --include='*.tsx'
Fix now
Use full class names, map objects, or safelist dynamic combinations in tailwind.config.ts
⚙ Quick Reference
9 commands from this guide
FileCommand / CodePurpose
tailwind.config.tsconst config: Config = {Tailwind content Config
css-modules-pattern.tsx/* ============================================CSS Modules
global-css-pattern.ts/* ============================================Global CSS
css-in-js-app-router.tsx'use client'CSS-in-JS in the App Router
postcss.config.mjs/** @type {import('postcss-load-config').Config} */PostCSS Config
class-merging-pattern.tsxinterface BadButtonProps {Tailwind Class Merging
dynamic-classes-tailwind.tsxinterface BadAlertProps {Tailwind with Server Components
theming-tailwind.ts/* ============================================Theming with Tailwind
shadcn-ui-setup.ts'use client'Using third-party CSS frameworks (shadcn/ui, daisyUI) in Nex

Key takeaways

1
Tailwind content config must list exact source directories
'./*/' scans node_modules and produces a 14MB+ CSS file
2
CSS Modules are the safest default for component styling
colocated, scoped, zero-runtime, work in all components
3
Global CSS imports are only allowed in root layouts
use CSS Modules for component-level styles
4
CSS-in-JS adds 12-15KB bundle size and causes FOUT
avoid in new app router projects
5
PostCSS plugin order matters
import → tailwindcss → autoprefixer is the standard pipeline
6
Dynamic Tailwind classes must use complete strings (object maps or clsx)
template literals are not statically analyzed
7
Add CSS file size monitoring to CI
a sudden spike indicates a content config issue
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how Tailwind's content configuration affects CSS output size and...
Q02SENIOR
Why can't you import global CSS in any component in the app router? How ...
Q03SENIOR
What are the trade-offs between Tailwind CSS, CSS Modules, and CSS-in-JS...
Q04SENIOR
How do you handle dynamic or conditional Tailwind class names in a way t...
Q05SENIOR
How would you implement a dark mode theming system in Next.js using Tail...
Q01 of 05SENIOR

Explain how Tailwind's content configuration affects CSS output size and what happened when a team used './**/*.{ts,tsx}' as their content glob.

ANSWER
Tailwind's content array defines which files to scan for class name usage. The glob './*/.{ts,tsx}' matches every .ts and .tsx file in the entire project, including node_modules, .next, and any other directory. In a typical Next.js project, node_modules contains hundreds of thousands of files, many with Tailwind class names. Tailwind generates CSS for every unique class combination found in all scanned files — this can produce a 10-15MB CSS file. Additionally, if a component directory isn't in the content array, its classes are treated as unused and purged — resulting in missing styles. The correct approach is to list exact source directories: ['./app/*/.{tsx}', './components/*/.{tsx}', './lib/*/.{ts}']. This produces a 50-150KB CSS file with all classes properly included. Always add a CI check that fails if the CSS file exceeds 500KB to catch content config regressions.
FAQ · 7 QUESTIONS

Frequently Asked Questions

01
Why is my Tailwind CSS file 10MB+ in production?
02
Can I use CSS Modules in Server Components?
03
Why does importing a .css file in a component throw a build error?
04
Should I use styled-components in a new Next.js app router project?
05
How do I handle dynamic Tailwind class names (template literals)?
06
What is the correct PostCSS plugin order for Next.js with Tailwind?
07
What's the difference between shadcn/ui and daisyUI for Next.js?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
Data Fetching in Next.js 16: Server Components, Streaming, and Suspense
24 / 56 · Next.js
Next
Image and Font Optimization in Next.js 16