Tailwind CSS in Astro: Refactoring Every Page and Fixing What Breaks

Refactor Astro pages and components to Tailwind CSS v4, fix scoped-style leaks, handle generated Markdown, and avoid dynamic class build failures.

Quick answer

How do you refactor all Astro pages to Tailwind v4?

Replace component layout rules with complete Tailwind utility classes, but keep intentional global styles for Markdown rendered through <Content />. Search for old scoped style blocks, map dynamic variants to complete class strings, give reading pages a narrower layout, and run a production build before pushing.

Astro Content Lab site after full Tailwind v4 refactor — clean card layout on Reviews page
First-hand experience: Based on direct hands-on use. Built live on astro-content-lab.vercel.app — every error in this article happened in real time.

This was supposed to be one article.

Part 1 covered installing Tailwind v4 and refactoring the base files: layout, header, footer, homepage, and blog listing. Straightforward enough.

Then I started Part 2. Ten files left. I figured two hours, maybe three.

It took longer. Not because the refactoring was hard — it wasn’t. But because a ghost CSS bug showed up halfway through, h1 headings on three pages lost their styling, and I spent a while staring at DevTools before I understood what was actually happening.

That’s what this article is about. The refactor, the bug, and the decisions that aren’t in the official Tailwind docs.


I originally planned this as one article. Then I saw how many files were left and decided two focused articles were better than one exhausting one.


What Was Left After Part 1

After Part 1, six listing pages and components were done. What remained:

Slug pages (detail pages):

  • src/pages/blog/[slug].astro
  • src/pages/reviews/[slug].astro
  • src/pages/compares/[slug].astro
  • src/pages/guides/[slug].astro

Components:

  • src/components/ProjectBanner.astro
  • src/components/Callout.astro

Each of these still had <style> tags with plain CSS variables. The goal: remove every <style> tag, replace with Tailwind utility classes, keep the site looking the same.


The Refactor Pattern

Every slug page followed the same pattern. Take reviews/[slug].astro as the example.

Before — plain CSS:

<div class="post-header">
  <a href="/reviews" class="back-link">← Back to Reviews</a>
  <span class="badge">Review</span>
  <h1>{review.data.title}</h1>
</div>

<style>
  .post-header {
    margin-bottom: 2rem;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid var(--color-border);
  }
  .back-link { font-size: 0.875rem; color: var(--color-primary); }
  .badge { background: #ede9fe; color: #5b21b6; border-radius: 999px; }
</style>

After — Tailwind:

<div class="mb-8 pb-6 border-b border-slate-200">
  <a href="/reviews" class="text-sm text-indigo-600 hover:underline">← Back to Reviews</a>
  <span class="inline-block mt-3 text-xs font-semibold px-2.5 py-0.5 rounded-full bg-violet-100 text-violet-700">Review</span>
  <h1 class="text-3xl font-bold text-slate-900 mt-2 mb-2">{review.data.title}</h1>
</div>

No <style> tag. Classes directly on elements. Delete 40 lines, write 4.

That’s the whole pattern. For most files, it went fine.

Then I got to the listing pages and noticed something wrong.


The Ghost CSS Bug

After refactoring reviews/[slug].astro, the h1 on the reviews.astro listing page lost its styling.

“Reviews” was rendering with no bold, no size. Just plain text.

The code was correct — class="text-3xl font-bold text-slate-900" was there. The classes were right. But something was overriding them.

I opened DevTools and clicked the h1. Here’s what showed up in the computed styles:

.page-header[data-astro-cid-aza7rbfb] h1[data-astro-cid-aza7rbfb] {
  margin-bottom: 0.5rem;
}

h1, h2, h3, h4, h5, h6 {
  font-size: inherit;
  font-weight: inherit;
}

That second rule — font-size: inherit; font-weight: inherit — is Tailwind preflight. It resets all headings to inherit from the parent element. Normally, Tailwind utility classes like text-3xl and font-bold override this with higher specificity.

But something was loading .page-header h1 with an Astro scoped attribute (data-astro-cid-aza7rbfb), which pulled the preflight reset into play at a higher specificity level.

I ran a search:

grep -r "page-header" src/

Output:

src/pages/reviews/[slug].astro:  .page-header {
src/pages/reviews/[slug].astro:  .page-header h1 { margin-bottom: 0.5rem; }
src/pages/reviews/[slug].astro:  .page-header p { color: var(--color-text-muted); margin: 0; }

There it was. The old <style> tag in reviews/[slug].astro still had .page-header rules left over from an earlier layout. The affected heading was rendered by that same page component, so Astro correctly gave the selector and matching markup the same scope attribute. The rule was not leaking into an unrelated route. It was still active exactly where its scoped selector was allowed to match.

The lesson: When refactoring a component, check its entire <style> block. Scoped styles prevent broad cross-component matching; they do not protect you from stale selectors that still match elements inside their own component.


Tailwind and Custom CSS — They Coexist

One question that came up during this refactor: if we’re switching to Tailwind, why do we still have CSS in global.css?

Good question. The answer is .prose.

Tailwind resets all browser defaults — including headings, lists, blockquotes, links. That’s intentional. In your own HTML, you add classes to re-apply the styles you want. That’s how Tailwind works.

But the Markdown content rendered by <Content /> is generated HTML. You can’t add Tailwind classes to a <h2> or <li> inside a .md file. The HTML is produced automatically.

So .prose in global.css is the solution:

/* global.css */
.prose h1 { font-size: 2rem; font-weight: 700; }
.prose h2 { font-size: 1.5rem; font-weight: 700; margin-top: 2rem; }
.prose p { margin-bottom: 1.25rem; }
.prose ul, .prose ol { padding-left: 1.5rem; margin-bottom: 1.25rem; }
/* ... and so on */

Then in each slug page, wrap <Content /> with the prose class:

<div class="post-body prose">
  <Content />
</div>

Tailwind handles: layout, spacing, colors, cards, badges, buttons — everything in your own HTML.

Custom CSS handles: everything inside Markdown-rendered content that Tailwind can’t reach.

This isn’t a Tailwind limitation to work around. It’s the intended pattern.


Dynamic Classes — The Lookup Object Rule

The guides/[slug].astro page has difficulty badges: beginner, intermediate, advanced — each a different color.

First instinct: template literals.

{/* This does NOT work */}
<span class={`bg-${difficulty}-100 text-${difficulty}-700`}>
  {guide.data.difficulty}
</span>

Tailwind builds by scanning source files for complete class strings. bg-${difficulty}-100 is never a complete string — so Tailwind never generates those classes. At runtime, the class name exists, but there’s no CSS for it.

The fix: a lookup object with full class strings.

---
const difficultyColors: Record<string, string> = {
  beginner: 'bg-emerald-100 text-emerald-700',
  intermediate: 'bg-amber-100 text-amber-700',
  advanced: 'bg-red-100 text-red-700',
}
---

<span class={`inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full capitalize ${difficultyColors[guide.data.difficulty] ?? 'bg-slate-100 text-slate-600'}`}>
  {guide.data.difficulty}
</span>

Every class string appears in full in the source file. Tailwind sees them. They get generated.

Rule: Never construct Tailwind class names with string interpolation. Always use full class strings, either directly or through a lookup object.


Content Width — One Size Does Not Fit All

After the refactor, something felt off visually. Blog and review pages looked too wide — lines were too long to read comfortably. But the listing pages needed that width to show cards properly.

The original BaseLayout used max-w-5xl for everything:

<div class="max-w-5xl mx-auto px-5">
  <slot />
</div>

I tried max-w-2xl for content pages. Too narrow — the text wrapped constantly and felt cramped. Tried max-w-3xl — nearly identical to where we started. Eventually landed on max-w-4xl for reading pages, max-w-5xl for listing pages.

The solution: a narrow prop in BaseLayout.

---
interface Props {
  title: string
  narrow?: boolean
}

const { title, narrow = false } = Astro.props
---

<div class={`${narrow ? 'max-w-4xl' : 'max-w-5xl'} mx-auto px-5`}>
  <slot />
</div>

Then on every slug page and the About page:

<BaseLayout title="..." narrow>

Listing pages (blog, reviews, compares, guides) don’t pass narrow — they get max-w-5xl by default.

This is how WordPress handles it too, just differently — page templates let you assign different layouts to different content types. In Astro, a prop does the same job. Less clicking, more explicit.


Components: ProjectBanner and Callout

Two components needed refactoring.

ProjectBanner — appears at the bottom of every content page. Links to the GitHub repo and the series. Before: a <style> block with custom classes and media queries. After: a single div with Tailwind flex utilities and sm: responsive prefixes.

<div class="mt-12 p-5 rounded-xl border border-violet-200 bg-gradient-to-br from-violet-50 to-blue-50">
  <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
    <!-- content -->
  </div>
</div>

The @media (max-width: 640px) block became flex-col sm:flex-row. Same behavior, no media query needed.

Callout — used inside MDX files to highlight important notes. Three types: info (blue), warning (amber), tip (green). Before: dynamic CSS classes (callout-info, callout-warning, callout-tip). After: a lookup object — same pattern as the difficulty badges.

---
const styles = {
  info:    'border-blue-400 bg-blue-50 text-blue-900',
  warning: 'border-amber-400 bg-amber-50 text-amber-900',
  tip:     'border-emerald-400 bg-emerald-50 text-emerald-900',
}
---

<div class={`border-l-4 px-4 py-3 rounded-r-lg my-4 ${styles[type]}`}>
  {title && <p class="font-bold text-sm mb-1">{title}</p>}
  <slot />
</div>

Before Pushing: npm run build

One thing worth doing before every push to GitHub: run a local build.

npm run build

npm run dev is permissive. Case-sensitive import errors — ProjectBanner vs projectbanner — pass silently on Mac because the filesystem isn’t case-sensitive. Vercel runs on Linux. Linux is case-sensitive. Your build fails in production, passes locally, and you wonder why.

npm run build catches this before it becomes a Vercel problem.

This project has been hit by that exact error twice. Running build locally is now a habit.


The Final Count

After Part 1 and Part 2:

  • CSS removed: ~943 lines of plain CSS across all files
  • Tailwind classes added: ~242 lines
  • <style> tags remaining: 0
  • Custom CSS remaining in global.css: the .prose block — intentional, not forgotten

The site looks the same. The code is smaller. There are no leftover component <style> tags to maintain, while the intentional .prose rules remain in global.css. Responsive behavior uses sm: prefixes instead of scattered @media blocks.

Is Tailwind better than plain CSS? For a project like this — yes. Not because utility classes are philosophically superior, but because co-locating styles with markup means fewer places to look when something breaks. The ghost CSS bug from this session is a perfect example: the dead style was in a <style> block that nobody was paying attention to, affecting a page nobody expected.

With Tailwind, dead component CSS is harder to accumulate. If you delete the element, its utility classes usually disappear with it. Global styles still need the same discipline they needed before.

Next, the series moves from styling to discoverability: one reusable Astro SEO component for canonical URLs, Open Graph data, and social cards.


Frequently Asked Questions

Why did my h1 lose bold and font-size after switching to Tailwind?
Tailwind v4 preflight resets heading size and weight. A leftover scoped style rule can also beat a utility through cascade order or specificity, making the failure look random. Inspect the rendered styles, find the winning rule, and remove obsolete component CSS instead of adding stronger overrides.
Do I need to remove all CSS when switching to Tailwind?
No. Tailwind and custom CSS can coexist. In this project, utilities handle component layout and UI, while global prose rules style the HTML rendered from Markdown through <Content />. The important part is assigning each styling job to one layer so the cascade stays understandable.
How do I handle different content widths in Astro with Tailwind?
Add a narrow prop to the shared BaseLayout component instead of choosing widths independently on every page. Listing pages can use max-w-5xl, while reading pages use max-w-4xl. One layout prop keeps the decision visible, reusable, and consistent when another content type is added.
Why does Tailwind dynamic class generation fail in Astro?
Tailwind v4 scans files at build time and only generates classes it finds as complete strings. Writing bg-${color}-100 means the full class name never appears in the source — Tailwind won't generate it. Use a lookup object with full class strings instead.
What is the .prose class for in Tailwind projects?
.prose is a custom CSS class in global.css that styles HTML elements generated from Markdown. Those elements do not carry utility classes from the source component. Without global prose rules or Tailwind Typography, headings, lists, links, code blocks, and blockquotes inside <Content /> lose their intended reading styles.