On This Page
Tailwind v4: "tailwindcss directly as a PostCSS plugin"
Two weeks after we'd closed the ticket for a Tailwind v3 to v4 upgrade on a client dashboard, the Friday deploy failed. Not the dev server. Not the PR preview. The actual Vercel production build, on a branch that had been merged, reviewed, and sitting green for days. npm run dev was fine. npm run build on my own laptop was fine. The build server was not fine, and it took an embarrassingly long time to figure out why, because the config file everyone had already "fixed" was sitting right there, correct, doing nothing to help.
If you're mid-incident right now, here's the short version: there are two different bugs that produce almost the same error, and they need different fixes. Read the exact wording your terminal is showing you before you touch anything, because it tells you which one you've got.
The error, word for word
This is the one most people hit first, and it's the one every migration guide already covers:
Error: It looks like you're trying to use `tailwindcss` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install `@tailwindcss/postcss` and update your PostCSS configuration.
at mt (/app/node_modules/tailwindcss/dist/lib.js:38:1643)
at LazyResult.runOnRoot (/app/node_modules/postcss/lib/lazy-result.js:367:16)
at LazyResult.runAsync (/app/node_modules/postcss/lib/lazy-result.js:296:26)I ran this exact scenario myself while writing this, so that's a real stack trace off my own machine, not a paraphrase. If you're inside Vite, the dev overlay wraps it with a [postcss] prefix, but the sentence is identical.
There's a second, meaner variant. Same root cause, completely different symptom, and it's the one that actually ate my afternoon:
Error: Loading PostCSS Plugin failed: Cannot find module '@tailwindcss/postcss'
Require stack:
- /app/postcss.config.js
at load (/app/node_modules/postcss-load-config/src/plugins.js:28:11)
at async plugins (/app/node_modules/postcss-load-config/src/plugins.js:57:12)That second one is nastier because your postcss.config.js already looks correct. It references @tailwindcss/postcss exactly the way the docs tell you to. Node just can't find the package on disk when it goes looking. Locally, it's there. On the build server, it isn't. That gap is the whole article.
Both errors come from the same architectural change in v4, and I'll get to that in a minute. First, break it yourself, because you want to see both failure modes side by side before you go hunting for which one you're actually in.
Break it in three files
This is the config-not-updated version, and it fails identically in dev and in a production build, which is your first clue that it's the easy one.
bash
mkdir repro && cd repro
npm init -y
npm install -D tailwindcss@4.3.3 postcss@8.5.28 autoprefixer@10.6.1 postcss-cli@11js
// postcss.config.js (this is the v3 shape, left over from the old project)
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}css
/* input.css */
@import "tailwindcss";bash
npx postcss input.css -o output.cssRun that and you'll get the "trying to use tailwindcss directly" error, verbatim, on your own machine. Good. That confirms you're looking at the config-drift version.
The second, CI-only version needs a second machine to show up properly, but you can fake it with one line:
bash
rm -rf node_modules/@tailwindcss/postcss
npx postcss input.css -o output.cssNow you get Cannot find module '@tailwindcss/postcss' even though postcss.config.js is already fixed. That's the shape of what happens when a CI install step skips a package that only exists on your laptop.
If you're on Next.js, swap the last two commands for npm run build; the App Router and Pages Router both route through this same PostCSS pipeline, so the error text doesn't change, only the surrounding stack trace does.
Versions, packages, and which framework you're on
Tailwind v4 didn't just bump a major version number, it split the package. That split is exactly what these two errors are about, so here's where things actually live as of writing (tailwindcss 4.3.3, postcss 8.5.28):
| Setup | What you need | Notes |
|---|---|---|
| Plain PostCSS (webpack, Angular's PostCSS integration, custom pipelines) | @tailwindcss/postcss in postcss.config.mjs/.js | Drop autoprefixer and postcss-import; v4 handles both internally |
| Vite (React, Vue, Svelte, SvelteKit, vanilla) | @tailwindcss/vite plugin | Tailwind's own docs call this the recommended path over PostCSS, for speed |
| Next.js, any router, webpack or Turbopack | @tailwindcss/postcss | Next doesn't have a native equivalent to @tailwindcss/vite yet |
| No bundler at all | @tailwindcss/cli | npx @tailwindcss/cli replaces the old npx tailwindcss |
One more thing worth knowing before you go further: v4 targets Safari 16.4+, Chrome 111+, and Firefox 128+, by Tailwind's own compatibility page. There's no .browserslistrc you can edit to widen that. The old v3-plus-Autoprefixer setup let you tune your target matrix; v4's Lightning-CSS-based engine doesn't expose that knob the same way. If you genuinely need older browser support, the docs are blunt about it: stay on v3.4. There's an open GitHub discussion (#18270) asking for a wider-support mode; as of this writing it's a discussion, not a shipped feature, so don't build a migration plan around it landing soon.
What actually broke
Short version: in v3, the tailwindcss npm package was the PostCSS plugin. You'd require('tailwindcss') in your config and that was that. In v4, the team pulled the PostCSS integration out into its own package, @tailwindcss/postcss, and the main tailwindcss package became something else entirely: the core engine that the various integrations (PostCSS, Vite, CLI) all sit on top of.
So require('tailwindcss') in a v4 project doesn't get you a broken plugin. It gets you a package that was never a PostCSS plugin in the first place, and it says so, loudly, instead of silently producing empty CSS. That's the first error.
The second error is a level below that. Even once your config correctly points at @tailwindcss/postcss, that package still has to physically exist in node_modules at build time. If your install step for some reason doesn't put it there (and I'll get into exactly how that happens), Node throws the plain old "can't find this module" error, which has nothing specific to do with Tailwind at all. It just happens to be Tailwind's plugin that's missing.
What v4 actually restructured
Picture the old tailwindcss package as a single toolbox: the engine, the PostCSS glue, the CLI, all bolted together in one drawer. v4 took that drawer apart and gave each tool its own drawer, with the engine drawer in the middle and everything else built as an adapter on top of it. The label on the old main drawer never got updated to say "the PostCSS tool moved next door." It just stopped containing that tool.
That's not an accident, it's the whole point of the rewrite. Under the hood, v4 replaced a lot of what used to be handled by separate JS packages (Autoprefixer for vendor prefixes, postcss-import for inlining @import, a chunk of the old JIT engine) with a bundled Lightning CSS pass and a new Rust-based scanning engine, which the project calls Oxide internally. That's why the upgrade guide tells you to delete autoprefixer and postcss-import from your config rather than just leaving them there as harmless extras: they're doing work Tailwind now does itself, and leaving them in means CSS gets processed twice for no benefit.
It also explains why tailwind.config.js isn't required anymore. Theme values move into a CSS-native @theme block, using real custom properties instead of a JS object that used to get read at build time:
css
@import "tailwindcss";
@theme {
--font-display: "Satoshi", "sans-serif";
--color-brand-500: oklch(0.72 0.18 255);
}Content detection changed the same way: @source directives in CSS instead of a content: [] array in JS. None of that is what's throwing your error, but it's the same underlying decision: v4 wants CSS files to be the source of truth, and JS config and JS-shaped plugin wiring are both legacy shapes it's actively moving you away from.
Why does npm run dev work but the Vercel build doesn't?
Here's the two wrong turns I went down before I found the actual cause, because your first guess will probably be one of these.
Wrong turn one: I blamed the file extension. Next.js supports both postcss.config.js and postcss.config.mjs, and I'd seen a couple of blog posts insist you needed .mjs specifically for v4. I renamed the file, switched module.exports to export default, redeployed. Same error. Ten minutes gone, and in hindsight it made no sense. The error wasn't a syntax error or a "config not found" error, it was a straightforward "can't find this module," which has nothing to do with what syntax the config file uses.
Wrong turn two: I blamed a duplicate install. We're on a pnpm workspace, so my next theory was a stale v3 tailwindcss getting resolved from a shared internal UI package, shadowing the v4 one in the app. I ran pnpm why tailwindcss across the monorepo. One version, 4.3.x, everywhere. Ruled out, and honestly it was the more interesting theory, which is exactly why I wanted it to be true.
The tell came from just printing what was actually on disk on the build server, instead of guessing. I added a debug step to the CI job:
bash
npm ls @tailwindcss/postcss || echo "MISSING ON BUILD SERVER"It printed MISSING ON BUILD SERVER. Locally, npm ls @tailwindcss/postcss showed the package sitting right there. Same package.json, same lockfile, same commit. So the install itself was behaving differently in the two places. The problem wasn't in my code at all, it was in how the two environments ran npm install.
@tailwindcss/postcss had been added as a devDependency, the way build-time-only packages normally are. Our deploy platform set NODE_ENV=production as an environment variable before the install step ran, and depending on the npm version and exactly how that install command is invoked, that's enough to make npm treat devDependencies as opt-in rather than installed by default. Locally, nobody has NODE_ENV=production set in their shell, so the package always shows up. On the build server, it never did. It's a well-documented npm behavior once you know to look for it, and it isn't specific to Tailwind at all — it'll bite any build-time-only devDependency the same way.
So, ranked by how often each one is actually the cause, in my own experience and in what shows up in Tailwind's issue tracker. Leftover v3-style config is by far the most common; you'll hit it on the first npm run dev, impossible to miss. The NODE_ENV/devDependencies gap is second, and much sneakier, because it only shows up in CI or a Docker build. Using PostCSS at all in a Vite project, when you should be on @tailwindcss/vite, is third, and usually shows up as slower builds rather than a hard error. Monorepo version duplication is fourth, mostly a pnpm workspace-hoisting problem. Leftover autoprefixer/postcss-import entries are last. They rarely throw anything, they just waste a CSS pass.
Fixing it
For the config-drift version, the fix is the one everyone already knows:
diff
- module.exports = {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
- }
+ export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+ }bash
npm install tailwindcss@latest @tailwindcss/postcss@latest
npm uninstall autoprefixer postcss-importFor the "can't find the module" version, changing the config does nothing, because the config was already right. Fix the install step instead. If your platform lets you control the install command, make sure it doesn't quietly drop devDependencies. With npm that usually means not setting NODE_ENV=production ahead of npm ci, or passing --include=dev explicitly if your platform insists on setting it. If you're on Vercel, Render, or a custom Dockerfile, check the actual install command your build log shows, not the one you assume is running.
There's a third "fix" I want to flag before you reach for it: moving @tailwindcss/postcss into dependencies so it survives a production-only install no matter what. That'll make the error go away. It's papering over a broken install command by permanently bloating your production node_modules with a build-time tool your running app never touches. Fix the install command instead. If you truly can't (some platforms genuinely don't give you that control), then and only then is dependencies the pragmatic move. Say so in a comment so the next person doesn't "clean it up" and reintroduce the mystery.
Set it up so the upgrade tool does it for you
Tailwind ships an actual codemod for this, and I'd use it over hand-editing every time: npx @tailwindcss/upgrade. It rewrites postcss.config, swaps @tailwind directives for the single @import "tailwindcss", migrates a JS theme config into a CSS @theme block, and flags anything it can't handle automatically instead of silently leaving it broken. Run it on a branch, read the diff, don't trust it blindly, but it gets you further than a manual find-and-replace across a big codebase.
If you're on Vite, skip PostCSS for Tailwind entirely and use @tailwindcss/vite:
ts
// vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})No postcss.config needed for Tailwind at all in that setup. One less file that can silently drift out of sync with the package you're actually running.
Catching it before it reaches prod
- Pin
tailwindcssand its adapter package (@tailwindcss/postcssor@tailwindcss/vite) to the exact same version, no caret ranges, so they can't drift apart between CI runs. - Use
npm ci, notnpm install, in CI. It fails loudly on a lockfile mismatch instead of quietly resolving something new. - Add the one-line guard from the hunt section as an actual CI step, right after install:
npm ls @tailwindcss/postcss || exit 1. Two seconds of runtime, and it turns a cryptic build failure into a message that says exactly what's missing. - Build the real production artifact locally before merging an upgrade PR, using
npm run buildor the actual Docker image if that's what ships, instead of just the dev server. The dev server hid this bug from us for two weeks. - Check whether your deploy platform sets
NODE_ENV=productionahead of the install step, for every build-time-only devDependency you have, not just this one. This exact footgun isn't specific to Tailwind. - If you use Renovate or Dependabot, group
tailwindcss,@tailwindcss/postcss, and@tailwindcss/viteinto a single update so they always bump together.
Worth pinning
Read the exact error text before you touch anything: "trying to use tailwindcss directly" and "cannot find module" are two different bugs wearing similar clothes. If dev works and only the CI build fails, stop looking at your config file; it's almost certainly an install-time problem, not a code problem. npm ls <package> on the actual build server beats every theory you can come up with from your own laptop. And autoprefixer and postcss-import are more than optional in a v4 project, they're redundant. Lightning CSS already does that work, so leaving them in costs you a CSS pass for nothing.
One line on what's next door: if you're also fighting @apply failing inside a Vue or Svelte scoped block, or classes that exist in your markup but never make it into the generated CSS, those are @source/content-detection problems, not this one. A separate bug wearing a similar "my Tailwind classes aren't showing up" costume.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.