Tailwind v4 + Astro: a pair made for content-heavy sites
This very site is the proof. rootsbit runs on Astro 5 + React 19 + Tailwind v4, served as a fully static build, and the move to Tailwind v4 via the official Vite plugin cut roughly 40% off our CSS payload and made the design tokens mentally simpler in a way I want to explain.
This is a practical post for anyone considering the same migration. I'll show the config, the OKLCH-based token system we use, and the three gotchas that almost bit us.
Why Tailwind v4 was worth the migration for a content site
The premise of Tailwind v4 is that the framework is now a CSS-native engine, not a PostCSS plugin. The official Tailwind v4 announcement covers the mechanics; what matters for content-heavy sites specifically is this: the new @theme directive lets you declare your design tokens in CSS, and the Vite plugin ships only the classes you actually use, not the ones Tailwind might generate. For a site with a blog, services, case studies, and 404 page, that's the difference between ~40kb of CSS and ~14kb.
ForAstro specifically, the marriage works because Astro already wants you to author components in a single-file, CSS-colocated style. Tailwind v4 leans into the same mental model — your theme is a CSS file, your utilities are inline, and there's no tailwind.config.ts to mentally jump between.
The Vite plugin setup with Astro
The config is smaller than you might expect. In astro.config.ts:
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
site: "https://rootsbit.com",
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
});That's it. No tailwind.config.ts. No content array to maintain. The plugin scans your source files automatically.
Then in src/styles/global.css:
@import "tailwindcss";
@import "@tailwindcss/typography";
@theme {
--color-primary: oklch(58% 0.13 35);
--color-primary-hover: oklch(52% 0.14 32);
--color-primary-soft: oklch(94% 0.04 40);
--color-accent: oklch(55% 0.12 145);
--color-accent-soft: oklch(94% 0.03 145);
--color-bg-base: oklch(98.5% 0.012 80);
--color-surface: oklch(99% 0.008 82);
--color-border: oklch(90% 0.015 75);
--color-text: oklch(22% 0.02 50);
--color-text-muted: oklch(48% 0.02 60);
--font-display: "Fraunces", ui-serif, Georgia, serif;
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}Tokens declared in @theme become utility classes automatically — bg-primary, text-text-muted, border-border, font-display. No config file lookup.
OKLCH tokens that respect dark mode
OKLCH is a color space that represents lightness, chroma, and hue in a way that matches how humans perceive color. The OKLCH color picker by Evil Martians is a good reference. For dark mode, we override the same token names under a .dark class:
.dark {
--color-bg-base: oklch(16% 0.012 50);
--color-surface: oklch(22% 0.015 50);
--color-border: oklch(30% 0.015 50);
--color-text: oklch(96% 0.01 80);
--color-text-muted: oklch(75% 0.02 75);
--color-primary: oklch(70% 0.15 38);
--color-primary-hover: oklch(76% 0.16 38);
--color-primary-soft: oklch(28% 0.06 38);
}Because the utility classes reference the token, not the raw color, you get dark mode for free once the token switches. This is the structure we describe on our services page — the same tokens drive every page, every component, light and dark.
The key insight: keep your token names stable between modes and only change the values. If you find yourself writing dark:bg-primary in your markup, you've broken the abstraction.
Gotcha 1: the @tailwindcss/typography plugin needs an explicit import
In v3, the typography plugin was auto-detected from your config. In v4, with the Vite plugin, you need to add it explicitly:
@import "tailwindcss";
@import "@tailwindcss/typography";The order matters — tailwindcss first, then typography. Missing this gives you a prose class that silently doesn't apply any styles, which is a painful 20 minutes to debug.
Gotcha 2: arbitrary OKLCH values in utilities
You can write text-[oklch(58%_0.13_35)] inline, but you shouldn't. The parser handles the underscores-as-spaces syntax, but inline OKLCH defeats the token system and makes dark mode more painful. Always declare tokens in @theme and reference them by name. The only legitimate use of inline OKLCH is one-off marketing flourishes you haven't decided to promote to the design system yet.
Gotcha 3: Astro's content collections and the prose plugin
Astro's content collections render markdown to HTML, and the prose class from the typography plugin styles that HTML. But prose doesn't know about the custom classes your markdown renderer might add. We worked around this by writing a small markdown-to-HTML renderer (which you can see at work on this very blog post) and styling the output classes alongside prose in global CSS.
The short version: prose gets you 80% of the way. The remaining 20% — custom callouts, styled code blocks, your brand's inline link treatment — should be a small set of explicit CSS classes, not a proliferation of prose: overrides.
What I'd tell someone starting fresh
If you're starting a content-heavy site today with Astro, start on Tailwind v4 from day one. Don't migrate mid-build. The migration from v3 to v4 is genuinely small if your config is clean, but it's still a half-day of work you don't need on a new project. The MDN guide on CSS custom properties is worthwhile reading alongside the migration — @property lets you add types to your custom properties, which Tailwind v4 understands and uses.
If you're scoping a content site and want a second opinion on the stack, reach out — we do 30-minute architecture calls for free. And if you want the design-philosophy companion to this post, the organic UI: why we built rootsbit around warm tones is the piece on the decisions behind the palette.
Next in this series: what the real cost of building a mobile app in India in 2026 actually is — with the numbers nobody else seems willing to publish.