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#
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minifyThat 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 baserules 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.
module.exports = {
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
}@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#
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minifynpx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --watchWith a bundler#
Vite and the PostCSS plugin minify as part of the normal production pipeline — you do not add a flag:
vite buildimport { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})export default {
plugins: {
'@tailwindcss/postcss': {},
cssnano: process.env.NODE_ENV === 'production' ? {} : false,
},
}next buildNext.js minifies CSS in production builds by default. No extra configuration.
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:
npx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify
ls -lh dist/app.css
gzip -c dist/app.css | wc -cnpx @tailwindcss/cli -i ./src/app.css -o ./dist/app.css --minify
(Get-Item dist/app.css).LengthWhat the numbers should look like:
| Signal | Reading |
|---|---|
| Minified output in the tens of kilobytes | Normal for a real application |
| Minified output over ~200 kB | Source detection is scanning something it should not |
| Gzip roughly a fifth of raw; Brotli smaller again | Expected — utility CSS is extremely repetitive and compresses well |
| The file grows a lot after adding one component | You 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.
function Button({ color, children }) {
return <button className={`bg-${color}-600 hover:bg-${color}-500`}>{children}</button>
}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:
<div class="text-{{ error ? 'red' : 'green' }}-600"></div><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:
@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":
@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:
@source "../node_modules/@acmecorp/ui-lib";Directories worth excluding#
@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:
/assets/app.a1b2c3d4.css
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: brEvery bundler emits hashed filenames by default. With the standalone CLI you have to arrange it yourself — or accept a shorter max-age and revalidation.
<link rel="preload">#
<!-- 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.
<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.
- 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.
- Use
font-display: swapwith a metric-matched fallback. Swap shows text immediately in the fallback; a matched fallback stops the swap from reflowing the page. - Set
size-adjust,ascent-overrideanddescent-overrideon the fallback so the two faces occupy the same space.
@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:
<div class="mx-auto max-w-sm p-4">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div class="aspect-video w-full bg-gray-100"></div>
<div class="space-y-2 p-4">
<div class="h-4 w-2/3 rounded bg-gray-200"></div>
<div class="h-3 w-full rounded bg-gray-100"></div>
<div class="h-3 w-5/6 rounded bg-gray-100"></div>
</div>
</div>
</div>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:
| Build | v3 | v4 |
|---|---|---|
| Full build | 378 ms | 100 ms |
| Incremental, new CSS | 44 ms | 5 ms |
| Incremental, no new CSS | 35 ms | 192 µ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#
npm ci
npm run build{
"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, notnpm 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
@sourceor 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#
- Minify.
--minifywith the CLI; automatic with Vite, PostCSS and framework builders. - Confirm every template directory is scanned. Especially git-ignored generated output and any dependency shipping class names.
- Grep for constructed class names. Search for
classorclassNamenext to a template literal. - Check the compressed size. Set a budget and enforce it in CI.
- Serve one hashed, immutably cached stylesheet with Brotli or gzip enabled.
- Preload fonts, not the stylesheet — and include
crossorigin. - Remove Autoprefixer and any v3
content/purgeconfiguration. - Verify a production build in a browser, not just the dev server.
Common mistakes#
- Shipping the Play CDN.
@tailwindcss/browsercompiles 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 findhttps://cdn.tailwindcss.comin your markup, that is the v3 CDN — doubly wrong.) - Committing an unminified build. Easy to miss when
docs/ordist/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
contentarray 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 staticin 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.
Related topics#
- Installation — the CLI, Vite and PostCSS setups
- Project Setup — file layout and scripts
- Arbitrary Values — why constructed class names fail
- Functions & Directives —
@source,@source not,@source inline() - Troubleshooting — diagnosing a class that produces no CSS