# Astro 5.x Project Setup Playbook
Production-tested patterns from the ActiveWizards site build (147 pages, 115 blog posts, 12 case studies, 5 service pages). Every code example is extracted from a running production site.
Table of Contents
1. Project Initialization
``bash
npm create astro@latest my-site
`
Recommended options:
- Template: Empty (start clean, not a starter)
- TypeScript: Yes, strict
- Install dependencies: Yes
After scaffolding:
`bash
cd my-site
npm install tailwindcss @tailwindcss/vite @astrojs/sitemap
npm install astro-robots-txt astro-compress astro-expressive-code
npm install astro-icon astro-pagefind
npm install rehype-slug rehype-autolink-headings rehype-external-links
npm install remark-reading-time mdast-util-to-string
npm install medium-zoom
`
Production package.json dependencies (proven stable):
`json
{
"name": "site-aw",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/rss": "^4.0.15",
"@astrojs/sitemap": "^3.7.0",
"@tailwindcss/vite": "^4.2.1",
"astro": "^5.17.1",
"astro-compress": "^2.3.9",
"astro-expressive-code": "^0.41.7",
"astro-icon": "^1.1.5",
"astro-pagefind": "^1.8.5",
"astro-robots-txt": "^1.0.0",
"mdast-util-to-string": "^4.0.0",
"medium-zoom": "^1.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0",
"rehype-slug": "^6.0.0",
"remark-reading-time": "^2.0.2",
"tailwindcss": "^4.2.1"
}
}
`
2. Tailwind v4 Integration
Tailwind v4 uses Vite plugin instead of PostCSS. No tailwind.config.js needed — tokens go in CSS @theme blocks.
. Use the Vite plugin directly:
`bash
npm install tailwindcss @tailwindcss/vite
`
In astro.config.mjs:
`js
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
`
In src/styles/global.css:
`css
@import "tailwindcss";
@theme {
--color-bg-primary: #0A0A0F;
--color-text-primary: #EDEDED;
--color-accent: #00D4AA;
--font-sans: 'Geist Sans', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
}
`
Import the CSS in your base layout:
`astro
import '../styles/global.css';
`
. All tokens live in CSS @theme blocks. Utility classes reference tokens via var() — e.g., bg-[var(--color-bg-primary)].
3. Content Collections
Astro 5.x uses
src/content.config.ts (not src/content/config.ts from v4).
src/content.config.ts
`ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
// GEO fields (for Generative Engine Optimization)
problem: z.string().optional(),
technology: z.string().optional(),
technologyVersion: z.string().optional(),
persona: z.string().optional(),
// SEO overrides
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
ogImage: z.string().optional(),
// Content
tldr: z.string().optional(),
relatedPosts: z.array(z.string()).default([]),
readingTime: z.number().optional(),
}),
});
const cases = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/cases' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
client: z.string().optional(),
industry: z.string().optional(),
technologies: z.array(z.string()).default([]),
metrics: z.array(z.object({
label: z.string(),
value: z.string(),
})).default([]),
image: z.string().optional(),
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
}),
});
const services = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/services' }),
schema: z.object({
title: z.string(),
description: z.string(),
icon: z.string().optional(),
order: z.number().default(0),
technologies: z.array(z.string()).default([]),
caseStudies: z.array(z.string()).default([]),
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
}),
});
export const collections = { blog, cases, services };
`
Content file structure
`
src/content/
blog/
my-first-post.md
another-post.md
cases/
project-alpha.md
services/
ai-agent-engineering.md
`
Frontmatter example (blog)
`yaml
title: "Building Production RAG Pipelines with LangChain"
description: "A complete guide to deploying retrieval-augmented generation in production."
publishedAt: 2025-11-15
updatedAt: 2026-01-20
tags: ["RAG", "LangChain", "Vector DB"]
problem: "How to build a production RAG pipeline"
technology: "LangChain"
technologyVersion: "0.3.x"
tldr: "
- Use chunking with overlap
- Embed with ada-002
"
Article body in Markdown...
`
Key patterns
- Use
z.coerce.date() for dates — handles both 2025-11-15 and "2025-11-15T00:00:00Z" strings
- Use
.default([]) for array fields to avoid undefined checks everywhere
- Use
.optional() for fields that not every post will have
- The
glob loader in v5 replaces the implicit directory-based loader from v4
4. Layout Hierarchy
`
BaseLayout (HTML shell, SEO, fonts, Nav, Footer)
└── BlogLayout (article header, TL;DR, article body, CTA, related posts)
└── CaseStudyLayout (metrics bar, hero image, prose body)
└── [page].astro (direct use for index pages, about, contact)
`
BaseLayout —
src/layouts/BaseLayout.astro
The HTML shell. Every page uses this.
`astro
import '../styles/global.css';
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import SEO from '../components/SEO.astro';
export interface Props {
title: string;
description: string;
canonical?: string;
ogImage?: string;
ogType?: 'website' | 'article';
publishedAt?: Date;
updatedAt?: Date;
jsonLd?: Record;
}
const props = Astro.props;
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
Skip to content
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.fade-in-up').forEach((el) => {
observer.observe(el);
});
import mediumZoom from 'medium-zoom';
mediumZoom('.prose img', {
margin: 40,
background: '#ffffff',
});
`
BlogLayout —
src/layouts/BlogLayout.astro
Wraps BaseLayout with article-specific structure:
`astro
import BaseLayout from './BaseLayout.astro';
export interface Props {
title: string;
description: string;
publishedAt: Date;
updatedAt?: Date;
tags?: string[];
tldr?: string;
readingTime?: number;
ogImage?: string;
relatedPosts?: any[];
}
const {
title, description, publishedAt, updatedAt,
tags = [], tldr, readingTime, ogImage, relatedPosts = [],
} = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, 'https://yoursite.com').href;
const pubDate = publishedAt.toISOString().split('T')[0];
// Build JSON-LD for TechArticle
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description,
url: canonicalUrl,
datePublished: publishedAt.toISOString(),
...(updatedAt && { dateModified: updatedAt.toISOString() }),
author: { '@type': 'Person', name: 'Your Name' },
publisher: { '@type': 'Organization', name: 'Your Org' },
...(readingTime && { timeRequired:
PT${readingTime}M }),
...(tags.length > 0 && { keywords: tags.join(', ') }),
};
title={
${title} | Your Site}
description={description}
ogType="article"
ogImage={ogImage}
publishedAt={publishedAt}
jsonLd={jsonLd}
>
{title}
{pubDate} {readingTime && · ${readingTime} min read}
{tldr &&
}