Tailwind CSS Handbook v4.3

Production Builds

Ship Tailwind v4 to production — output size, minification, source-detection pitfalls, CSS delivery, layout shift, CI and a build checklist.

There is almost nothing to configure. v4 generates only the utilities your source files actually use, on every build, in every environment — so "optimising for production" is mostly about not breaking that, and about how you deliver the file.

Overview#

BASH
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify

That is a production build. The output contains:

  • The utilities Tailwind found in your source files, and nothing else
  • The theme variables those utilities reference
  • Preflight, plus any @layer base rules you wrote
  • Anything you explicitly safelisted with @source inline() or @theme static

The size of a Tailwind stylesheet is a function of how many distinct utilities your markup uses, not of how big Tailwind is. Two hundred components that reuse the same fifty utilities cost roughly the same as one component that uses fifty.

There is no purge step#

Changed in v4 v2 had a purge option. v3 had a content array and a separate production mode. v4 has neither.

Legacy — Tailwind v3
module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
}
v4 — usually nothing at all
@import "tailwindcss";

Tailwind scans your project automatically, skipping:

  • Anything matched by .gitignore
  • node_modules
  • Binary files — images, video, archives, fonts
  • CSS files
  • Package-manager lockfiles

Every build is a production build. There is no NODE_ENV switch that changes what gets generated, which means the CSS you test locally is byte-for-byte the CSS you ship.

Minification#

With the CLI#

BASH
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify
Watch mode during development — no --minify
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --watch

With a bundler#

Vite and the PostCSS plugin minify as part of the normal production pipeline — you do not add a flag:

BASH
vite build
vite.config.js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()],
})

Tailwind v4 handles vendor prefixing and syntax lowering itself, so you do not need Autoprefixer. Remove it if you are carrying it over from v3 — it is a no-op at best and can rewrite modern colour syntax at worst.

Measuring the output#

Do not guess. Build, then look:

BASH
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify
ls -lh dist/app.css
gzip -c dist/app.css | wc -c

What the numbers should look like:

SignalReading
Minified output in the tens of kilobytesNormal for a real application
Minified output over ~200 kBSource detection is scanning something it should not
Gzip roughly a fifth of raw; Brotli smaller againExpected — utility CSS is extremely repetitive and compresses well
The file grows a lot after adding one componentYou introduced a new family of arbitrary values, or a safelist

Utility CSS is close to the best case for compression: the same property names and value patterns repeat constantly, so the transfer size is far below the raw size. Judge your budget on the compressed number, because that is what users download.

Source detection pitfalls#

This is where production breaks, and it almost always breaks the same way.

Dynamic class names#

Tailwind scans source files as plain text. It never runs your code, so a class name assembled at runtime does not exist at build time.

Broken — no CSS is generated
function Button({ color, children }) {
  return <button className={`bg-${color}-600 hover:bg-${color}-500`}>{children}</button>
}
Works — complete class names appear in the source
function Button({ color, children }) {
  const colorVariants = {
    blue: 'bg-blue-600 hover:bg-blue-500',
    red: 'bg-red-600 hover:bg-red-500',
  }
  return <button className={colorVariants[color]}>{children}</button>
}

The same rule applies to template languages:

Broken
<div class="text-{{ error ? 'red' : 'green' }}-600"></div>
Works
<div class="{{ error ? 'text-red-600' : 'text-green-600' }}"></div>

Class names from outside your codebase#

When class names genuinely arrive from a CMS field, a database column or an API response, there is no source file to scan. Safelist them:

CSS
@import "tailwindcss";

/* Editors can pick any of these badge colours */
@source inline("bg-{red,amber,green,blue,violet}-{100,500,700}");
@source inline("{hover:,focus:,}underline");

Brace expansion keeps this manageable — {start..end..step} generates numeric ranges, and a trailing empty item in a list means "and the bare class too":

CSS
@source inline("{hover:,}bg-red-{50,{100..900..100},950}");

Dependencies that ship class names#

node_modules is not scanned. A component library whose markup carries Tailwind classes needs registering:

CSS
@source "../node_modules/@acmecorp/ui-lib";

Directories worth excluding#

CSS
@source not "../src/legacy";
@source not "../tests/fixtures";

Faster builds, and no phantom utilities generated from text that merely looks like class names.

Delivering the CSS#

One stylesheet#

Ship a single Tailwind stylesheet for the whole site. Splitting it per route duplicates the shared utilities across bundles and costs you an extra request; the whole point of a utility system is that the second page reuses the first page's CSS from cache.

Cache headers#

Serve a content-hashed filename with a long, immutable cache:

TEXT
/assets/app.a1b2c3d4.css

Cache-Control: public, max-age=31536000, immutable
Content-Encoding: br

Every bundler emits hashed filenames by default. With the standalone CLI you have to arrange it yourself — or accept a shorter max-age and revalidation.

HTML
<!-- Redundant: a stylesheet in <head> is already discovered and fetched first -->
<link rel="preload" as="style" href="/assets/app.css">
<link rel="stylesheet" href="/assets/app.css">

A <link rel="stylesheet"> in the document head is render-blocking and found by the browser's preload scanner immediately. Preloading it as well adds markup, risks a double fetch if the as or crossorigin attributes do not match exactly, and buys nothing.

Preload is worth it in two cases:

  • A stylesheet the browser cannot discover early — one injected by JavaScript, or referenced from another stylesheet with @import.
  • Font files. These are discovered late, because the browser must parse the CSS first. Preload them and include crossorigin — fonts are always fetched anonymously, and a preload without it downloads the file twice.
HTML
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter.woff2" crossorigin>

Avoiding layout shift#

Fonts are the most common source of cumulative layout shift in a Tailwind site, because the utility system makes everything else easy to size.

  1. Self-host, and preload. A third-party font host adds a DNS lookup, a TLS handshake and a stylesheet round-trip before the font is even requested.
  2. Use font-display: swap with a metric-matched fallback. Swap shows text immediately in the fallback; a matched fallback stops the swap from reflowing the page.
  3. Set size-adjust, ascent-override and descent-override on the fallback so the two faces occupy the same space.
app.css
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: swap;
  font-weight: 100 900;
}

/* A local fallback tuned to match Inter's metrics */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}

@theme {
  --font-sans: "Inter", "Inter Fallback", ui-sans-serif, system-ui, sans-serif;
}

Framework font loaders — next/font, @nuxt/fonts, unfont — generate the adjusted fallback for you. With next/font remember @theme inline, because the family is exposed as a runtime CSS variable.

Reserve space for anything else that arrives late, so the page does not jump when it loads:

Loading preview…

aspect-video on the image slot and fixed heights on the text lines mean the final content drops into space that already exists. scrollbar-gutter-stable v4.3 does the same job for scroll containers that gain a scrollbar as content loads.

Engine performance#

The v4 engine was rewritten, and the numbers are the reason the "production mode" distinction disappeared:

Buildv3v4
Full build378 ms100 ms
Incremental, new CSS44 ms5 ms
Incremental, no new CSS35 ms192 µs

Incremental rebuilds that produce no new CSS complete in microseconds, so watch mode keeps up with typing. In CI, a full build is fast enough that caching the CSS step is rarely worth the complexity.

CI setup#

A minimal pipeline
npm ci
npm run build
package.json
{
  "scripts": {
    "dev": "npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --watch",
    "build": "npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify"
  }
}

Three things worth adding:

  • npm ci, not npm install. A lockfile-exact install means the Tailwind version that built locally is the one that builds in CI.
  • A size budget. Fail the build if the compressed stylesheet crosses a threshold you choose. It catches a stray @source or an accidental safelist the day it lands, not a quarter later.
  • Build the real pages. A visual or DOM check on a production build is the only reliable way to catch a dynamic class name that no local page happened to render.

Build checklist#

  1. Minify. --minify with the CLI; automatic with Vite, PostCSS and framework builders.
  2. Confirm every template directory is scanned. Especially git-ignored generated output and any dependency shipping class names.
  3. Grep for constructed class names. Search for class or className next to a template literal.
  4. Check the compressed size. Set a budget and enforce it in CI.
  5. Serve one hashed, immutably cached stylesheet with Brotli or gzip enabled.
  6. Preload fonts, not the stylesheet — and include crossorigin.
  7. Remove Autoprefixer and any v3 content/purge configuration.
  8. Verify a production build in a browser, not just the dev server.

Common mistakes#

  • Shipping the Play CDN. @tailwindcss/browser compiles CSS in the user's browser on every page load. It is a prototyping tool — it costs a large JavaScript download and a flash of unstyled content. Build a real stylesheet before you deploy. (And if you find https://cdn.tailwindcss.com in your markup, that is the v3 CDN — doubly wrong.)
  • Committing an unminified build. Easy to miss when docs/ or dist/ is checked in and the build script differs from the one CI runs. Make the committed artefact come from the same command.
  • Dynamic class names. The single biggest cause of "it works locally, styles are missing in production".
  • Keeping Autoprefixer. v4 handles prefixing itself.
  • Leaving a v3 content array in place. It is not read, so it quietly gives false confidence that your paths are configured.
  • Safelisting to fix a missing class. Fix the class name instead; a safelist ships CSS forever whether or not anything uses it.
  • Preloading the main stylesheet. Redundant at best, a double fetch at worst.
  • @theme static in a large theme. It emits every variable in the namespace, used or not. Scope it to the tokens JavaScript actually reads.
  • Assuming old browsers. v4 targets Safari 16.4+, Chrome 111+ and Firefox 128+. If you must support older engines, stay on v3 — no build flag backports the CSS features v4 relies on.