NoWaterProgramming

Migrating Tailwind v3 to v4 on Astro: What the Upgrade Tool Automated, the Two Errors It Left, and the CSS That Grew 28%

We ran npx @tailwindcss/upgrade on a production Astro site, moving Tailwind 3.4.19 to 4.3.3. The tool rewrote the PostCSS config, translated and deleted tailwind.config.mjs, and renamed classes across 31 files, all correctly. It also left two build-breaking errors specific to Astro, and the main stylesheet came out 28% larger gzipped.

12 min read
Share:

Migrated Tailwind 3.4.19 to 4.3.3 on Astro 7.1.4, on 2026-09-03.

npx @tailwindcss/upgrade did more than we expected and less than enough. On a real Astro site, one command rewrote the PostCSS config, translated the JavaScript config into CSS, deleted tailwind.config.mjs, and renamed classes across 31 files, every change correct. Then the build failed twice, on two errors that are specific to how Astro bundles component styles and that the tool could have prevented. And the main stylesheet came out 28% larger gzipped than it was on v3.

This is the field report to sit next to our Tailwind v4 overview, which covers the model and the trade-offs in general. This one is a single migration, measured end to end, with the diffs.

The site being migrated

The site is selim.services, which is one of our own (see the disclosure at the end). It is a good migration subject because nothing about its setup is exotic:

  • Astro 7.1.4, tailwindcss 3.4.19.
  • A hand-written postcss.config.cjs with tailwindcss and autoprefixer, rather than the @astrojs/tailwind integration.
  • global.css with the three @tailwind base/components/utilities directives and a font @import above them.
  • A 17-line tailwind.config.mjs: darkMode: "class", a content array, future: { hoverOnlyWhenSupported: true }, and a theme.container block with center: true and padding: "1rem". No custom colours in the config.
  • 23 @apply directives: most in global.css, and a handful inside <style> blocks in two .astro components.

Those two component <style> blocks are where this stopped being a one-command job.

What one command did, and got right

npx @tailwindcss/upgrade needs Node 20 or newer.1 It ran on Node 24 and exited clean. In one pass it made every one of these changes:

  • Dependencies. tailwindcss 3.4.19 to 4.3.3, installed @tailwindcss/postcss, and removed autoprefixer. v4 does vendor prefixing internally through Lightning CSS, so autoprefixer is dead weight.2

  • PostCSS config. { tailwindcss, autoprefixer } became { '@tailwindcss/postcss' }.

  • The config file. tailwind.config.mjs deleted entirely. Seventeen lines to zero.

  • global.css. The three @tailwind directives became a single @import 'tailwindcss'; the font @import gained a layer(base); and it appended a compatibility block that sets border-color back to the v3 default of gray-200, because v4's default is currentColor.

  • Dark mode. darkMode: "class" became @custom-variant dark (&:is(.dark *)) in the CSS. The upgrade guide does not even mention darkMode, so this one was a bonus.

  • The container utility. theme.container became an @utility rule. The upgrade guide says the center and padding options "no longer exist" in v4.1 The tool did not just drop them; it re-expressed them, keeping the real values:

    @utility container {
      margin-inline: auto;
      padding-inline: 1rem;
    }
    css
  • A future flag. future: { hoverOnlyWhenSupported: true } was dropped, because that behaviour is the default in v4.

  • Class renames. Across 31 template files. Every distinct rename we checked was correct against the v4 scale:

    v3v4
    outline-noneoutline-hidden
    rounded-smrounded-xs
    backdrop-blur-smbackdrop-blur-xs
    shadowshadow-sm
    leading-[1.5]leading-normal
    leading-[1.25]leading-tight
    tracking-[0.1em]tracking-widest
    -mx-[140px]mx-[-140px]
    aspect-[16/10]aspect-16/10
    bg-white/[0.02]bg-white/2
    [overflow-wrap:anywhere]wrap-anywhere

    Where no v4 token matched, for example tracking-[0.12em], it correctly left the arbitrary value alone.

If the site had used a JavaScript config only for content and a couple of flags, this would have been the whole job. It did not stay that simple.

Error 1: @apply in an Astro style block

The first astro build after the upgrade failed:

Cannot apply unknown utility class `md:mt-20`. Are you using CSS modules or
similar and missing `@reference`?
  Location: src/components/blog/Sources.astro:88:1

Sources.astro has a <style> block that uses @apply with utility classes. The upgrade tool had gone into that block and renamed a class inside it ([overflow-wrap:anywhere] to wrap-anywhere), so it clearly parsed the block. It did not add the one line that makes @apply work there in v4.

The cause is documented, in prose, in the upgrade guide: "stylesheets that are bundled separately from your main CSS file (e.g. CSS modules files, <style> blocks in Vue, Svelte, or Astro, etc.) do not have access to theme variables, custom utilities, and custom variants defined in other files."1 The fix is the @reference directive, which imports your main stylesheet for resolution without duplicating any output:3

@reference "../../styles/global.css";
css

added as the first line of each affected <style> block. Two components needed it.

The tool knows exactly which files this applies to, because it just edited them. It renames the classes and leaves the directive for the build to demand.

Error 2: @reference and the PostCSS plugin

With the @reference lines in, the build failed differently:

[postcss] ENOENT: no such file or directory, open '<project>/tailwindcss'

@reference "../../styles/global.css" pulls that file in for resolution. global.css contains @import 'tailwindcss'. Astro's bundled postcss-import sees the bare specifier and tries to open ./tailwindcss as a file. The @tailwindcss/postcss plugin the upgrade tool just configured never gets to handle it. This is a known friction point for v4 on Astro.4

The fix is the one the upgrade guide recommends for Vite users anyway, and Astro is a Vite project: move off the PostCSS plugin to @tailwindcss/vite.2

npm install -D @tailwindcss/vite
rm postcss.config.cjs
bash
// astro.config.mjs
import tailwindcss from "@tailwindcss/vite";
 
export default defineConfig({
  vite: { plugins: [tailwindcss()] },
  // ...rest unchanged
});
js

autoprefixer was already gone, so nothing else needed PostCSS and the config file could be deleted outright. After this, astro build completed and produced all 192 pages.

The upgrade tool does not make this change. It migrates whatever PostCSS setup it finds to the new PostCSS plugin and leaves you there, which on Astro is a setup that breaks the moment @reference enters the picture.

The dark variant works, with a caveat

The line the tool generated:

@custom-variant dark (&:is(.dark *));
css

&:is(.dark *) matches an element that is a descendant of something carrying class="dark". This site toggles dark mode by putting that class on <html>. <html> is not a descendant of itself, so a dark: utility placed directly on <html> would not take. :is() also carries the specificity of its most specific argument, where :where() contributes none.

For this site nothing on <html> needed a dark: utility, so the generated line is fine as written. But if you toggle by class on the root element, the form our overview recommends is safer:

@custom-variant dark (&:where(.dark, .dark *));
css

It matches the toggled element itself as well as its descendants, and keeps specificity flat.5 Worth checking which one you were handed.

The CSS got bigger

We built both versions with astro build, with the stylesheet emitted as a file rather than inlined, and measured the main sheet:

rawgzip -9
v338,430 B8,473 B
v457,568 B10,825 B
change+19,138 B (+50%)+2,352 B (+28%)

v4 exposes every design token as a CSS custom property on :root by default: "Tailwind CSS v4.0 takes all of your design tokens and makes them available as CSS variables by default."2 Every default colour, every spacing step, every font family and easing curve is written into the output whether a utility references it or not, and @property rules are emitted for animatable utilities. On a site that uses a narrow slice of the default palette, that is a fixed cost with nothing on the other side of it. It is recoverable, by disabling colour families in @theme, but the out-of-the-box number goes up.

Our overview measured this blog's entire v4 stylesheet at 13.3 KB gzipped, which is small. Both statements hold: v4 output is small in absolute terms and larger than the v3 output for the same source. If the reason for upgrading is bundle size, measure before you commit to it.

Where this is weak

  • One site, and a small one. Around 200 pages, a modest config, one team's conventions. A large application with a heavily customised theme and many plugins will surface migration issues this site cannot, and its bundle arithmetic will differ.
  • The config was already simple. darkMode, content, one future flag, a container block, and no custom colours in tailwind.config. Sites with a large theme.extend are where the CSS-first translation is worth watching, and this migration does not exercise that path.
  • Build verified, not rendered. The v4 build produces every page and every class rename checks out, but this is not a full visual regression. The border and ring default changes that the compatibility block papers over can still look slightly wrong somewhere until screenshots are compared.
  • @tailwindcss/vite was the fix here; it is not always on the table. An older Astro, or a non-Vite bundler, would need a different way out of the @reference and PostCSS collision.
  • Version-specific. @tailwindcss/upgrade at 4.3.3, Astro 7.1.4. Both move fast, and a later upgrade tool may close one or both of these gaps.

Doing it on your own site

  1. Commit or stash first. The tool edits in place across your whole src.
  2. Node 20 or newer. Run npx @tailwindcss/upgrade.
  3. Read the diff before trusting it. The class renames are mechanical and were right here; the config translation is where a custom theme needs your eyes.
  4. Build. If it fails on Cannot apply unknown utility class ... missing @reference, add @reference "path/to/your/main.css"; as the first line of every component <style> block that uses @apply.
  5. If the next failure is ENOENT ... tailwindcss and you are on Vite or Astro, install @tailwindcss/vite, wire it into the Vite plugins, and delete your postcss.config.
  6. Check the generated @custom-variant dark line against how you toggle dark mode.
  7. Measure the stylesheet before and after. Expect it to grow.

FAQ

Is Tailwind v4 smaller than v3?

Not automatically. For the site measured here the main stylesheet grew from 8,473 to 10,825 bytes gzipped, a 28% increase, because v4 emits every default theme token as a CSS variable on :root whether a utility uses it or not.2 You can recover some of that by disabling unused colour families in @theme, but the default output is larger for a site that uses a small part of the palette.

Does npx @tailwindcss/upgrade handle everything?

Most of it. On this migration it updated dependencies, rewrote the PostCSS config, translated and then deleted tailwind.config.mjs, and renamed classes across 31 files, all correctly. It did not add the @reference lines that @apply needs inside Astro <style> blocks, and it did not move the project onto @tailwindcss/vite; both were required before the build would pass.

Why does @apply fail after upgrading to v4?

In v4, a stylesheet bundled separately from your main CSS file, which includes a <style> block in an Astro, Vue or Svelte component and any CSS module, has no access to your theme, custom utilities or custom variants.1 @apply there fails with "Cannot apply unknown utility class". Add @reference "path/to/main.css"; at the top of that block to import the context without duplicating output.3

Do I still need autoprefixer and postcss-import with Tailwind v4?

No. v4 bundles @import handling and does vendor prefixing through Lightning CSS.2 The upgrade tool removed autoprefixer on its own. postcss-import can go too; on Vite or Astro it comes from the bundler rather than your config, which is also why a bare @import "tailwindcss" reached by @reference can fail to resolve under the PostCSS plugin.

What happened to theme.container and its center and padding options in v4?

They were removed. The container utility still exists but is no longer configurable through the theme.1 The upgrade tool rewrites a v3 theme.container block into an @utility container rule in your CSS, keeping your actual values, for example margin-inline: auto; padding-inline: 1rem;. You edit that rule directly from then on.

How do I get class-based dark mode after migrating?

The upgrade tool writes @custom-variant dark (&:is(.dark *)) into your CSS in place of v3's darkMode: "class".6 If you toggle dark mode with a class on <html> and ever put a dark: utility on <html> itself, use @custom-variant dark (&:where(.dark, .dark *)) instead, which matches the toggled element and keeps specificity flat.5

Disclosure

The site migrated in this article is selim.services, one of our own. The migration and every measurement here are our own primary work on that codebase, not a reference to anything published on the site.

Sources

Checked 2026-09-03.

Sources

  1. Tailwind CSS upgrade guide - that npx @tailwindcss/upgrade requires Node 20 or newer and migrates dependencies, configuration and templates; that separately bundled stylesheets including Astro <style> blocks lose access to theme variables, custom utilities and custom variants in v4; that theme.container's center and padding options no longer exist and can be replaced with an @utility container rule; that autoprefixer and postcss-import can be removed; and that Vite users should migrate from the PostCSS plugin to @tailwindcss/vite.

  2. Tailwind CSS v4.0 announcement - that v4 exposes all design tokens as CSS variables on :root by default, that @tailwindcss/vite is the recommended integration for Vite users, and that @import bundling and vendor prefixing are handled internally through Lightning CSS in place of external plugins.

  3. Tailwind CSS functions and directives: the @reference directive - that @reference "main.css" imports theme variables, custom utilities and custom variants into a component <style> block or CSS module so that @apply resolves there, without duplicating CSS in the output.

  4. tailwindlabs/tailwindcss issue 18055: Astro site failing after upgrading Tailwind v3 to v4 - reports of utility classes failing to resolve after a v3 to v4 upgrade on Astro under the PostCSS plugin, with the dedicated Vite plugin as the path that resolves it.

  5. Tailwind CSS dark mode - the @custom-variant dark declarations for class-based and data-attribute dark mode, including the &:where(.dark, .dark *) form that also matches the toggled element.

  6. Tailwind CSS functions and directives: the @custom-variant directive - that @custom-variant defines a custom variant such as a class-based dark.

Related Posts

14 min read
How a pixel-comparison overlay actually works in the browser: why a wrapper div breaks mix-blend-mode, click-through without pointer-events juggling, keeping scroll on its fast path, and hardening per-origin persisted state.
By NoWaterProgramming Team
12 min read
Tailwind v4 moved configuration out of JavaScript and into CSS. What that changes for theming and dark mode, what the upgrade breaks, the browsers it drops, and the measured CSS size of a real site.
By NoWaterProgramming Team