Astro and Tailwind CSS have become a natural pairing for building content-focused sites. Astro handles the rendering model — server-first, minimal JavaScript by default — and Tailwind handles the styling layer with utility classes that compose directly in markup. When you add Tailwind CSS v4 to the mix, the two feel even more aligned: both are moving toward a model where configuration lives in fewer files and the output is leaner than ever.
This guide covers how each tool works, why they complement each other, and exactly how to wire them together using current APIs. Code examples are accurate for Astro 5 / Astro 7 and Tailwind CSS v4.
What Astro does differently
Most JavaScript frameworks ship a component runtime to the browser regardless of whether the page is interactive. Astro flips this: every component is rendered to static HTML at build time by default, and JavaScript is only sent when you explicitly opt in. The result is fast load times and high Lighthouse scores without any manual optimization.
The mechanism behind this is called Islands Architecture. An Astro page is mostly static HTML; interactive regions — a mobile menu, a pricing calculator, a live search box — are isolated "islands" that hydrate independently. You control when each island becomes interactive using client directives:
client:load— hydrates immediately on page load. Use for above-the-fold interactive components.client:idle— hydrates once the browser is idle. Good for non-critical UI.client:visible— hydrates when the component scrolls into the viewport. Ideal for comments, footers, and lazy sections.client:media="(max-width: 768px)"— hydrates only when a media query matches.client:only="react"— skips server rendering entirely; client-only for components that cannot run on the server.
A React or Vue or Svelte component used without a directive costs zero JavaScript at runtime. That same component with client:visible costs JavaScript only when the user scrolls to it. The model is explicit and surgical.
Astro project structure
A new Astro project looks like this after npm create astro@latest:
src/
components/ ← .astro, .tsx, .vue, .svelte components
layouts/ ← page shell components
pages/ ← file-based routing (.astro, .md, .mdx)
content/ ← content collection source files
styles/ ← global CSS
content.config.ts ← collection schemas
public/ ← static assets (copied as-is)
astro.config.mjs
Files in src/pages/ automatically become routes. src/pages/about.astro becomes /about; src/pages/blog/[slug].astro generates one page per blog post. No routing library to configure.
Content collections: typed, validated content at scale
Content collections are Astro's built-in system for managing structured content — blog posts, product listings, team members, or any repeating data shape. Define a schema once; every entry in the collection is validated against it at build time, and TypeScript types are generated automatically.
Collections are configured in src/content.config.ts:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
description: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
The loader tells Astro where to find entries. glob() matches local files; you can also load from a remote API by writing a custom loader. The schema is a Zod object — the same validation library you may know from tRPC or React Hook Form.
Querying a collection in a page component is straightforward:
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</li>
))}
</ul>
getCollection() returns all entries that pass the optional filter function. The second argument — ({ data }) => !data.draft — excludes drafts from the built output. All returned data fields are fully typed based on your Zod schema.
What changed in Tailwind CSS v4
Tailwind CSS v4 is a near-complete rewrite. The headline changes that affect how you use it day-to-day:
- No
tailwind.config.jsrequired. Configuration moves into your CSS file using the@themedirective. - Lightning CSS replaces PostCSS as the default transformer, making builds significantly faster.
- The Vite plugin replaces the old
@astrojs/tailwindintegration for Astro projects. The@astrojs/tailwindpackage is deprecated for v4. - All utilities are included by default — no more purge configuration. Tailwind scans your source files and tree-shakes unused classes automatically.
- CSS custom properties are first-class. Every theme token becomes a real CSS variable, accessible anywhere in your stylesheet.
Installing Tailwind CSS v4 in an Astro project
Step 1: Create the project
npm create astro@latest my-site
cd my-site
Step 2: Install Tailwind and the Vite plugin
npm install tailwindcss @tailwindcss/vite
Step 3: Register the Vite plugin in astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
Notice this goes in the vite.plugins array, not in the top-level Astro integrations array. This is intentional — Tailwind v4 operates at the Vite level.
Step 4: Create your global CSS file
Create src/styles/global.css:
@import "tailwindcss";
That single line replaces the old three-directive block (@tailwind base; @tailwind components; @tailwind utilities;). Everything is included through the import.
Step 5: Import the stylesheet in your layout
---
// src/layouts/BaseLayout.astro
import '../styles/global.css';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My Site</title>
</head>
<body>
<slot />
</body>
</html>
Import the CSS once in your root layout and it applies site-wide. No need to import it in individual pages or components.
Customizing your design with @theme
The @theme directive is where you define custom design tokens. Instead of editing a tailwind.config.js, you write CSS custom properties in a specific namespace, and Tailwind generates the corresponding utility classes automatically.
@import "tailwindcss";
@theme {
/* Custom color palette */
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-500: oklch(0.55 0.18 250);
--color-brand-900: oklch(0.25 0.10 250);
/* Custom font families */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Playfair Display', Georgia, serif;
/* Custom breakpoints */
--breakpoint-xs: 480px;
/* Custom spacing */
--spacing-18: 4.5rem;
}
After defining these, Tailwind generates classes like bg-brand-500, text-brand-50, font-display, xs:grid-cols-2, and mt-18. The tokens also become real CSS custom properties (var(--color-brand-500)) available anywhere in your stylesheet without any extra setup.
The namespace prefix drives the generated class category:
--color-*→ color utilities (bg-,text-,border-,ring-, etc.)--font-*→font-familyutilities--breakpoint-*→ responsive prefixes--spacing-*→ spacing utilities (m-,p-,gap-,w-,h-, etc.)--ease-*→ transition timing functions
Putting it together: a styled Astro component
Here is a complete example of a blog post card component using Astro syntax and Tailwind v4 utility classes:
---
// src/components/PostCard.astro
interface Props {
title: string;
description: string;
href: string;
pubDate: Date;
}
const { title, description, href, pubDate } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<article class="rounded-xl border border-gray-200 p-6 hover:shadow-md transition-shadow">
<time class="text-sm text-gray-500" datetime={pubDate.toISOString()}>
{formattedDate}
</time>
<h2 class="mt-2 text-xl font-semibold text-gray-900">
<a href={href} class="hover:text-brand-500 transition-colors">
{title}
</a>
</h2>
<p class="mt-3 text-gray-600 leading-relaxed">{description}</p>
<a
href={href}
class="mt-4 inline-block text-sm font-medium text-brand-500 hover:text-brand-900"
>
Read more →
</a>
</article>
The brand-500 and brand-900 classes reference the custom tokens defined in the @theme block above. If you change the color value in CSS, it updates everywhere with no find-and-replace across config files.
Component scoped styles in Astro
Astro components support a <style> block that is automatically scoped to that component — no CSS Modules setup, no naming convention required. This works alongside Tailwind, not instead of it. Use Tailwind for common utilities and scoped styles for one-off component-specific rules:
<article class="rounded-xl border p-6">
<slot />
</article>
<style>
article {
/* This only applies to <article> inside this component */
container-type: inline-size;
}
</style>
Astro adds a unique data attribute to the element and scopes the selector automatically during build. No style leakage, no specificity fights.
Performance: why this stack scores well
The performance story for Astro plus Tailwind v4 has a few clear reasons:
- Zero JavaScript by default. A static Astro page sends no framework runtime. Hydration is opt-in per component.
- Tailwind ships only what you use. The v4 engine scans your templates and removes every unused class. A typical page gets a CSS file in the single-digit kilobyte range.
- Lightning CSS is fast. Tailwind v4's internal CSS engine is written in Rust. Build times drop noticeably on larger projects compared to the PostCSS pipeline.
- Static HTML is cacheable. Server-rendered Astro output is plain HTML that CDN edge nodes can cache and serve from the nearest location to the user.
Common gotchas when migrating from Tailwind v3
If you are upgrading an existing project, a few things will need updating:
- Remove
@astrojs/tailwindfromintegrationsinastro.config.mjsand add the Vite plugin instead. - Replace the three-directive block (
@tailwind base/components/utilities) with a single@import "tailwindcss". - Move any
theme.extendvalues fromtailwind.config.jsinto an@themeblock in your CSS file. Custom properties use double-dash prefix and kebab-case:--color-primary-600rather than a nested JS object. - Arbitrary values with
bg-[#ff0000]syntax still work — no changes needed there. - The
darkMode: 'class'config option becomes@custom-variant dark (&:where(.dark, .dark *));in your CSS file.
Conclusion
Astro and Tailwind CSS v4 share an underlying philosophy: remove what the browser does not need, and make the developer workflow match the output model. Astro ships static HTML with selective JavaScript; Tailwind ships only the CSS classes your templates actually use. Together, they give you a stack that performs well without tuning and stays maintainable as the project grows. The key concepts to carry forward are Islands for JavaScript isolation, content collections for typed data, the Vite plugin for correct Tailwind v4 integration, and @theme for keeping your design tokens in one CSS file.