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].astrosrc/pages/reviews/[slug].astrosrc/pages/compares/[slug].astrosrc/pages/guides/[slug].astro
Components:
src/components/ProjectBanner.astrosrc/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
.proseblock — 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.