How I Switched an Astro Site from Plain CSS to Tailwind v4

Install Tailwind CSS v4 in Astro, choose a safe migration strategy, and refactor global styles and layouts using lessons from a real content site.

Quick answer

How do you install Tailwind CSS in Astro?

Run npx astro add tailwind in your project terminal. Astro installs the packages and updates astro.config.mjs automatically. Then add @import 'tailwindcss' to your global CSS file and remove the old CSS. That's it — Tailwind v4 requires no config file.

VS Code showing Astro component with Tailwind utility classes instead of a style tag
First-hand experience: Based on direct hands-on use. This follows the Tailwind v4 migration I completed on astro-content-lab. The setup and refactor decisions come from that working project.

At article 4, I built the CSS foundation for this project. Reset, typography, variables, layout utilities. CSS I understood and could read.

By article 15, the project had accumulated:

  • A 200-line global.css
  • Scoped styles in every single .astro component
  • Duplicate margin, padding, and color values scattered across 12 files
  • A mobile responsive fix that took an embarrassingly long time because I had to find the right @media block in the right file

That’s not a disaster. But it’s friction. And it compounds.

Tailwind solves the friction — not by making CSS simpler, but by making it unnecessary to write in the first place.


Why plain CSS stops scaling

Plain CSS has one fundamental problem: it lives in a different place from the HTML it styles.

When something looks wrong, you check the HTML, then jump to the CSS file, find the right class, make the change, jump back to the HTML. For three components, this is fine. For thirty, it’s constant context switching.

Naming things makes it worse. Every component needs class names. .post-item, .post-item-content, .post-item-body, .post-item-link. These names mean something when you write them. They mean nothing three weeks later.

And then there’s the specificity spiral. One component’s .title accidentally affects another. You add specificity to fix it. Then something else breaks.


What Tailwind actually is

Tailwind is a utility-first CSS framework. Instead of writing class names and then writing CSS for those class names, you use pre-built utility classes directly in your HTML.

Plain CSS:

/* styles.css */
.card {
  padding: 1rem;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: white;
}
<!-- template -->
<div class="card">content</div>

Tailwind:

<!-- no CSS file needed -->
<div class="p-4 border border-slate-200 rounded-lg bg-white">content</div>

Same result. One approach — write CSS, reference it. Other approach — write CSS as classes, directly where you use it.

The trade-off is obvious: Tailwind HTML looks noisier. A component with ten style rules becomes a class list that takes two lines.

The benefit is also obvious once you’ve used it: you never leave the HTML file to style something. Everything is in one place.


Tailwind v4 vs v3 — what changed

Most tutorials you’ll find online use Tailwind v3. When you run npx astro add tailwind today, you get Tailwind v4. They’re different enough that v3 tutorials will mislead you.

The biggest change: no tailwind.config.js.

In v3, you configured Tailwind in a JavaScript file — content paths, theme extensions, plugins.

In v4, configuration happens in CSS using @theme blocks. No separate config file.

/* v3 — needed tailwind.config.js */
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#4f46e5'
      }
    }
  }
}
/* v4 — configure directly in CSS */
@theme {
  --color-primary: #4f46e5;
}

This is cleaner. But it breaks every v3 tutorial that tells you to create tailwind.config.js.

Also changed in v4: some utility class names, the way the Vite plugin integrates, and the purging mechanism. For our purposes — building a content site — the differences are manageable. Just know that if a class from a tutorial doesn’t work, it might be a v3 vs v4 difference.


Step 1: Install Tailwind

One command:

npx astro add tailwind

Astro’s CLI handles everything:

◇  Resolved packages.
◇  Continue? Yes
◇  Dependencies installed.
◇  Continue? Yes
   success  Added the following integration to your project:
   - tailwind

After installation, astro.config.mjs looks like this:

import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import tailwindcss from '@tailwindcss/vite'

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

Tailwind v4 integrates as a Vite plugin, not an Astro integration. That’s new compared to v3.


Step 2: The migration decision

Before touching any file, decide: refactor gradually or rewrite completely?

Gradual refactor:

  • Keep existing CSS
  • Add Tailwind classes component by component
  • Old CSS and Tailwind coexist
  • Lower risk, slower cleanup

Full rewrite:

  • Delete old CSS
  • Rewrite every component from scratch in Tailwind
  • Clean break, consistent codebase
  • More work upfront, cleaner result

I chose the full rewrite. Here’s why:

Mixing plain CSS and Tailwind long-term creates two systems that need to be maintained. The --color-primary variable in global.css and the text-indigo-600 class in the template both do the same thing — but they’re not connected. Change one, the other doesn’t update.

For a project at this stage — 15 components, not 150 — a full rewrite takes a few hours and produces a clean codebase. Worth it.


Step 3: Rewrite global.css

Delete everything in src/styles/global.css and replace with:

@import "tailwindcss";

@theme {
  --color-primary: #4f46e5;
  --color-primary-dark: #3730a3;
  --color-accent: #10b981;
  --color-warning: #f59e0b;
  --color-text: #0f172a;
  --color-muted: #64748b;
  --color-border: #e2e8f0;
  --color-bg: #ffffff;
  --color-bg-soft: #f8fafc;

  --font-sans: system-ui, -apple-system, sans-serif;
  --font-mono: 'Fira Code', monospace;
}

/* Base */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  font-family: var(--font-sans);
  color: var(--color-text);
  background: var(--color-bg);
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
}

/* Prose — article body styles */
.prose h1 { font-size: 2rem; font-weight: 700; margin-bottom: 1rem; line-height: 1.3; }
.prose h2 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; margin-top: 2rem; line-height: 1.3; }
.prose h3 { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; margin-top: 1.5rem; }
.prose p { margin-bottom: 1.25rem; }
.prose a { color: var(--color-primary); text-decoration: none; }
.prose a:hover { text-decoration: underline; }
.prose ul, .prose ol { padding-left: 1.5rem; margin-bottom: 1.25rem; }
.prose li { margin-bottom: 0.4rem; }
.prose code {
  font-family: var(--font-mono);
  font-size: 0.875rem;
  background: #f1f5f9;
  border: 1px solid var(--color-border);
  padding: 0.15rem 0.4rem;
  border-radius: 4px;
}
.prose pre {
  background: #1e1e2e;
  color: #cdd6f4;
  padding: 1.25rem;
  border-radius: 8px;
  overflow-x: auto;
  margin-bottom: 1.5rem;
  font-size: 0.875rem;
  line-height: 1.6;
}
.prose pre code { background: none; border: none; padding: 0; color: inherit; }
.prose blockquote {
  border-left: 4px solid var(--color-primary);
  padding: 0.75rem 1.25rem;
  margin: 1.5rem 0;
  background: var(--color-bg-soft);
  border-radius: 0 8px 8px 0;
  color: var(--color-muted);
  font-style: italic;
}
.prose img { max-width: 100%; height: auto; border-radius: 8px; }
.prose hr { border: none; border-top: 1px solid var(--color-border); margin: 2rem 0; }
.prose table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; font-size: 0.9375rem; }
.prose thead { background: var(--color-bg-soft); }
.prose th { text-align: left; padding: 0.75rem 1rem; font-weight: 600; border-bottom: 2px solid var(--color-border); }
.prose td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border); }
.prose tr:last-child td { border-bottom: none; }
.prose tr:hover td { background: var(--color-bg-soft); }

Two things to notice:

@import "tailwindcss" — this single line imports all Tailwind utilities. No other configuration needed to start using classes like flex, p-4, text-slate-500.

@theme {} — this replaces tailwind.config.js. Define CSS custom properties here and Tailwind generates matching utility classes automatically.

.prose classes — kept as regular CSS because they style content rendered from Markdown. Tailwind utilities can’t reach inside Markdown-rendered HTML, so .prose styles handle article body typography.


Step 4: Rewrite BaseLayout

---
import Header from '../components/Header.astro'
import Footer from '../components/Footer.astro'
import '../styles/global.css'

interface Props {
  title: string
}

const { title } = Astro.props
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body class="min-h-screen flex flex-col">
    <Header />
    <main class="flex-1 py-10">
      <div class="max-w-5xl mx-auto px-5">
        <slot />
      </div>
    </main>
    <Footer />
  </body>
</html>

No <style> tag. Five Tailwind classes replace 20 lines of CSS.

min-h-screen flex flex-col on body + flex-1 on main pushes the footer to the bottom of the page — a layout trick that’s annoying in plain CSS and trivial in Tailwind.


Step 5: Rewrite Header

---
---

<header class="border-b border-slate-200 bg-white sticky top-0 z-50">
  <div class="max-w-6xl mx-auto px-5 flex items-center justify-between h-16">
    <a href="/" class="font-bold text-lg text-slate-900 hover:text-indigo-600 transition-colors no-underline whitespace-nowrap flex-shrink-0">
      Astro <span class="text-indigo-600">Content Lab</span>
    </a>

    <button class="menu-toggle hidden max-[768px]:flex flex-col gap-1.5 bg-transparent border-0 cursor-pointer p-1 flex-shrink-0" aria-label="Toggle menu" aria-expanded="false">
      <span class="block w-5.5 h-0.5 bg-slate-900 rounded transition-all"></span>
      <span class="block w-5.5 h-0.5 bg-slate-900 rounded transition-all"></span>
      <span class="block w-5.5 h-0.5 bg-slate-900 rounded transition-all"></span>
    </button>

    <nav id="site-nav" class="flex gap-6 max-[768px]:hidden max-[768px]:absolute max-[768px]:top-16 max-[768px]:left-0 max-[768px]:right-0 max-[768px]:bg-white max-[768px]:border-b max-[768px]:border-slate-200 max-[768px]:flex-col max-[768px]:px-5 max-[768px]:py-2 max-[768px]:shadow-md">
      <a href="/blog" class="text-slate-500 text-sm font-medium hover:text-indigo-600 transition-colors no-underline whitespace-nowrap max-[768px]:py-3 max-[768px]:border-b max-[768px]:border-slate-100">Blog</a>
      <a href="/reviews" class="text-slate-500 text-sm font-medium hover:text-indigo-600 transition-colors no-underline whitespace-nowrap max-[768px]:py-3 max-[768px]:border-b max-[768px]:border-slate-100">Reviews</a>
      <a href="/compares" class="text-slate-500 text-sm font-medium hover:text-indigo-600 transition-colors no-underline whitespace-nowrap max-[768px]:py-3 max-[768px]:border-b max-[768px]:border-slate-100">Compares</a>
      <a href="/guides" class="text-slate-500 text-sm font-medium hover:text-indigo-600 transition-colors no-underline whitespace-nowrap max-[768px]:py-3 max-[768px]:border-b max-[768px]:border-slate-100">Guides</a>
      <a href="/about" class="text-slate-500 text-sm font-medium hover:text-indigo-600 transition-colors no-underline whitespace-nowrap max-[768px]:py-3">About</a>
    </nav>
  </div>
</header>

<script>
  const toggle = document.querySelector('.menu-toggle')
  const nav = document.querySelector('#site-nav')
  toggle?.addEventListener('click', () => {
    const isOpen = nav?.classList.toggle('!flex')
    toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false')
  })
</script>

The mobile nav uses max-[768px]: prefix — Tailwind’s arbitrary breakpoint syntax. Below 768px, the nav hides and the hamburger shows. The !flex toggle in the script overrides the hidden state when the button is clicked.

No <style> tag. The hamburger animation — three lines becoming an X — would need CSS or a small <style> block. For now, the toggle just shows/hides the menu. Animation comes in the component library article.


---
const year = new Date().getFullYear()
---

<footer class="border-t border-slate-200 bg-slate-50 py-8 mt-16">
  <div class="max-w-6xl mx-auto px-5 flex items-center justify-between flex-wrap gap-4">
    <p class="text-slate-500 text-sm m-0">
      © {year}
      <a href="https://github.com/doancongtuan/astro-content-lab" target="_blank" class="text-slate-500 hover:text-indigo-600 transition-colors">Astro Content Lab</a>.
      Built with Astro by
      <a href="https://doancongtuan.com" target="_blank" class="text-slate-500 hover:text-indigo-600 transition-colors">Steven Doan</a>.
    </p>
    <nav class="flex gap-5">
      <a href="/blog" class="text-slate-500 text-sm hover:text-indigo-600 no-underline transition-colors">Blog</a>
      <a href="/reviews" class="text-slate-500 text-sm hover:text-indigo-600 no-underline transition-colors">Reviews</a>
      <a href="/compares" class="text-slate-500 text-sm hover:text-indigo-600 no-underline transition-colors">Compares</a>
      <a href="/guides" class="text-slate-500 text-sm hover:text-indigo-600 no-underline transition-colors">Guides</a>
      <a href="/about" class="text-slate-500 text-sm hover:text-indigo-600 no-underline transition-colors">About</a>
    </nav>
  </div>
</footer>

Step 7: Rewrite the home page

The home page has the most CSS — hero section, card grid, about box, buttons. All of it becomes Tailwind classes:

---
import BaseLayout from '../layouts/BaseLayout.astro'
import { getCollection } from 'astro:content'

const posts = await getCollection('posts')
const reviews = await getCollection('reviews')
const latestPosts = posts.slice(0, 3)
const latestReviews = reviews.slice(0, 2)
---

<BaseLayout title="Astro Content Lab — Learn Astro from Scratch">

  <section class="py-16 text-center border-b border-slate-200 mb-12">
    <h1 class="text-4xl font-bold text-slate-900 max-w-2xl mx-auto mb-4 leading-tight">
      Learn Astro by Building a Real Content Site
    </h1>
    <p class="text-lg text-slate-500 max-w-xl mx-auto mb-8">
      A hands-on series for WordPress users and beginners who want to understand modern web development — from static site to mini CMS.
    </p>
    <div class="flex gap-3 justify-center flex-wrap">
      <a href="/blog" class="inline-block px-5 py-2.5 bg-indigo-600 text-white font-semibold rounded-lg hover:bg-indigo-700 transition-colors no-underline">
        Read the Blog
      </a>
      <a href="https://github.com/doancongtuan/astro-content-lab" target="_blank" class="inline-block px-5 py-2.5 border border-slate-200 text-slate-900 font-semibold rounded-lg hover:border-indigo-500 hover:text-indigo-600 transition-colors no-underline">
        View on GitHub
      </a>
    </div>
  </section>

  <section class="mb-14">
    <div class="flex items-center justify-between mb-6">
      <h2 class="text-2xl font-bold text-slate-900">Latest Posts</h2>
      <a href="/blog" class="text-sm text-indigo-600 font-medium hover:underline">See all →</a>
    </div>
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
      {latestPosts.map((post) => (
        <a href={`/blog/${post.id}`} class="flex flex-col justify-between border border-slate-200 rounded-xl p-5 no-underline text-slate-900 hover:border-indigo-500 hover:shadow-md transition-all group">
          <div>
            <h3 class="text-base font-semibold mb-2 group-hover:text-indigo-600 transition-colors">{post.data.title}</h3>
            <p class="text-sm text-slate-500 m-0">{post.data.description}</p>
          </div>
          <span class="text-sm text-indigo-600 font-medium mt-4 block">Read more →</span>
        </a>
      ))}
    </div>
  </section>

  <section class="mb-14">
    <div class="flex items-center justify-between mb-6">
      <h2 class="text-2xl font-bold text-slate-900">Latest Reviews</h2>
      <a href="/reviews" class="text-sm text-indigo-600 font-medium hover:underline">See all →</a>
    </div>
    <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
      {latestReviews.map((review) => (
        <a href={`/reviews/${review.id}`} class="flex flex-col justify-between border border-slate-200 rounded-xl p-5 no-underline text-slate-900 hover:border-indigo-500 hover:shadow-md transition-all group">
          <div>
            <span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full bg-violet-100 text-violet-700 mb-2">Review</span>
            <h3 class="text-base font-semibold mb-2 group-hover:text-indigo-600 transition-colors">{review.data.title}</h3>
            <p class="text-sm text-slate-500 m-0">{review.data.description}</p>
            <p class="text-amber-500 text-sm mt-2 m-0">
              {'★'.repeat(Math.floor(review.data.rating))}{'☆'.repeat(5 - Math.floor(review.data.rating))} {review.data.rating}/5
            </p>
          </div>
          <span class="text-sm text-indigo-600 font-medium mt-4 block">Read review →</span>
        </a>
      ))}
    </div>
  </section>

  <section class="bg-slate-50 border border-slate-200 rounded-xl p-8">
    <h2 class="text-2xl font-bold text-slate-900 mt-0 mb-3">What is Astro Content Lab?</h2>
    <p class="text-slate-600 mb-3">
      This is the demo project for the series <strong>"Learn Astro from Scratch"</strong> — a practical guide for WordPress users who want to understand how modern static sites work.
    </p>
    <p class="text-slate-600 mb-4">Every page on this site was built step by step, documented in the series. The source code is public on GitHub.</p>
    <a href="/about" class="inline-block px-5 py-2.5 border border-slate-200 text-slate-900 font-semibold rounded-lg hover:border-indigo-500 hover:text-indigo-600 transition-colors no-underline">
      About this project →
    </a>
  </section>

</BaseLayout>

No <style> tag. 200 lines of CSS replaced by utility classes inline.


What changed on the page

One thing I noticed immediately: changing the site’s max-width from too narrow to comfortable took changing one class — max-w-3xl to max-w-5xl. In plain CSS, that was a variable change plus checking every place the variable was used.

That’s Tailwind in practice. Small changes, zero hunting.


What’s still plain CSS

Not everything moves to Tailwind utilities. Two things stay as CSS:

Body and base styles — font, color, line-height. These apply globally and don’t belong as classes on individual elements.

.prose styles — the article body typography. Markdown renders to plain HTML — no way to add Tailwind classes to elements Astro generates from .md files. The .prose class on the wrapper div lets CSS reach inside.

Everything else — layout, spacing, colors, responsive behavior — becomes Tailwind classes.


What’s next

This article covered the global foundation: CSS setup, layout, header, footer, and home page.

The next article completes the refactor across listing pages, detail pages, and utility components. It also covers the CSS bug that made me distrust my own h1 for an afternoon.


Frequently Asked Questions

What is the difference between Tailwind v3 and Tailwind v4 in Astro?
Tailwind v4 dropped the tailwind.config.js file. Configuration now happens directly in CSS using @theme blocks. The installation command is the same — npx astro add tailwind — but the setup and some class names differ. Most tutorials online still show v3 syntax.
Do I need to delete my CSS file when adding Tailwind to Astro?
No. Keep your global CSS file and add @import 'tailwindcss' at the top. Tailwind makes its utilities available through that import. The same file can still hold intentional base typography, design tokens, and prose rules for HTML generated from Markdown content.
Should I refactor everything to Tailwind at once?
It depends. For a small project, rewriting everything at once is cleaner. For a larger project, refactoring component by component is safer. This article takes the full rewrite approach — delete the old CSS, start fresh with Tailwind. The next article completes the refactor across all pages.
Why use Tailwind instead of CSS variables and plain CSS?
I chose Tailwind because it reduced context switching and CSS naming work while this small content site was changing quickly. The trade-off is longer class lists in the markup. Plain CSS remains a good choice when the team prefers semantic class names or already has a stable design system.
Can I use Tailwind and plain CSS together in Astro?
Yes. Tailwind and CSS can coexist. Component-level style tags still work, and global CSS remains useful for base typography or rendered Markdown. The risk is inconsistency, not incompatibility. Decide which layer owns each kind of styling so the same element is not being controlled by two systems.