Most Astro tutorials stop at pnpm create astro. You get a src/pages/index.astro file, a friendly welcome page, and the implication that you are done. You are not. By the time you need TypeScript strict mode, Tailwind CSS 4 with design tokens, self-hosted fonts, Expressive Code blocks, build-time Mermaid diagrams, and a deployment pipeline that enforces Lighthouse 100/100/100/100 — you are patching together Stack Overflow answers from three different Astro major versions.
This post builds the foundation that every subsequent post in this series depends on. We scaffold a production-grade Astro 5 project with the exact tooling this blog runs on.
Why Astro 5 for Content Sites
Before touching the terminal, the framework choice deserves justification. If you are building a content-heavy site — a blog, documentation portal, marketing site, or knowledge base — Astro’s architecture makes a specific bet: ship zero JavaScript by default, add interactivity only where it matters.
Next.js, Remix, and SvelteKit are application frameworks. They assume every page needs a JavaScript runtime. Astro assumes the opposite. Static pages are static. Interactive components — a search dialog, a chart, a theme toggle — hydrate independently as “islands” in a sea of static HTML.
| Framework | Default JS | Content Performance | Interactive Content |
|---|---|---|---|
| Astro 5 | Zero JS on static pages | Lighthouse 100/100 achievable with zero effort | Islands hydrate independently — one slow chart does not block the page |
| Next.js 15 | React runtime on every page (~85KB min) | Requires careful RSC architecture to minimize JS | Full-page hydration — everything ships or nothing does |
| Hugo | Zero JS | Fastest raw build times | No component model — interactivity requires manual script tags |
| SvelteKit | Svelte runtime (~15KB) | Good but never truly zero-JS | Full-page hydration with Svelte components |
The tradeoff is real: Astro is not the right choice for a SaaS dashboard or a real-time collaboration tool. But for anything where the primary value is reading — which describes most of the web — it delivers the best performance-to-complexity ratio available.
Step 1: Scaffold and Configure TypeScript
Start with the official scaffolding tool, then immediately tighten the configuration.
pnpm create astro@latest blogcraft -- --template minimal --typescript strictcd blogcraftThe --template minimal flag gives you a clean slate: one page, no sample content, no starter CSS. The --typescript strict flag sets up tsconfig.json with Astro’s strictest preset, but we need to go further.
{ "extends": "astro/tsconfigs/strict", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}The @/* path alias eliminates the ../../../ import chains that make refactoring painful. Every import reads @/components/Header.astro regardless of file depth.
Create an .npmrc file at the project root:
auto-install-peers=trueThis tells pnpm to automatically install peer dependencies — critical for packages like rehype-mermaid that declare mermaid and playwright as peers.
Step 2: Directory Structure
Here is the directory layout we are building toward. Every file has a single responsibility. Every path is predictable.
Create this structure now:
mkdir -p src/{layouts,components/islands,content/{blog,authors,series},styles,assets/images/heroes}mkdir -p scripts publicThe key insight: .astro files are static components. They render at build time, produce zero JavaScript, and use <slot /> for composition. React .tsx files live in components/islands/ — the name is a constant reminder that these are interactive islands, not default building blocks.
Step 3: Tailwind CSS 4 Setup
Tailwind CSS 4 changed everything about configuration. There is no tailwind.config.js. There is no @astrojs/tailwind integration. The entire configuration lives in CSS.
Install the dependencies:
pnpm add tailwindcss @tailwindcss/vitenpm install tailwindcss @tailwindcss/viteyarn add tailwindcss @tailwindcss/viteCreate the global stylesheet with design tokens defined in a CSS-first @theme block:
@import "tailwindcss";
@theme { /* Typography */ --font-serif: "Source Serif 4 Variable", "Georgia", serif; --font-sans: "Satoshi", system-ui, sans-serif; --font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
/* Spacing scale */ --spacing-prose: clamp(1rem, 5vw, 3rem);
/* Radius */ --radius-sm: 0.25rem; --radius-md: 0.5rem; --radius-lg: 0.75rem;}
/* Light theme (default) */:root { --color-bg: #faf8f5; --color-bg-surface: #ffffff; --color-text: #1a1a1a; --color-text-muted: #6b6b6b; --color-border: #e5e0d8; --color-accent: #0D6B6E; --color-accent-hover: #0a5456;}
/* Dark theme */[data-theme="dark"] { --color-bg: #1a1a1a; --color-bg-surface: #262626; --color-text: #e8e4df; --color-text-muted: #9a9a9a; --color-border: #3a3a3a; --color-accent: #2dd4bf; --color-accent-hover: #5eead4;}
body { font-family: var(--font-serif); background-color: var(--color-bg); color: var(--color-text);}Every color in every component references these CSS variables. When dark mode activates via the data-theme attribute on <html>, every color flips automatically. No conditional classes, no JavaScript color calculations.
Step 4: Font Setup
Self-hosted fonts eliminate FOUT (Flash of Unstyled Text) and avoid third-party network requests. We use three typefaces: Source Serif 4 for body text, JetBrains Mono for code, and Satoshi for headings.
pnpm add @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-mononpm install @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-monoyarn add @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-monoSatoshi is not on Fontsource. Download the WOFF2 files from Fontshare, verify the license permits web use, and place them in public/fonts/. Register them with a @font-face declaration in global.css:
@font-face { font-family: "Satoshi"; src: url("/fonts/Satoshi-Variable.woff2") format("woff2"); font-weight: 300 900; font-display: swap;}Import the Fontsource packages in your base layout (covered in Step 6). The font-display: swap directive ensures text remains visible during font loading — a Core Web Vitals requirement.
Step 5: astro.config.ts — The Full Production Configuration
This is where integration order matters. Expressive Code must register before @astrojs/mdx. The rehype-mermaid plugin needs Playwright’s Chromium installed. The Tailwind Vite plugin replaces the old Astro integration.
import { defineConfig } from 'astro/config';import tailwindcss from '@tailwindcss/vite';import expressiveCode from 'astro-expressive-code';import mdx from '@astrojs/mdx';import react from '@astrojs/react';import sitemap from '@astrojs/sitemap';import cloudflare from '@astrojs/cloudflare';import rehypeSlug from 'rehype-slug';import rehypeAutolinkHeadings from 'rehype-autolink-headings';import rehypeMermaid from 'rehype-mermaid';import remarkGfm from 'remark-gfm';
export default defineConfig({ site: 'https://master.blogcraft.pages.dev', output: 'static', adapter: cloudflare(),
integrations: [ // ORDER MATTERS: Expressive Code BEFORE mdx expressiveCode({ themes: ['github-light', 'github-dark'], }), mdx(), react(), sitemap(), ],
markdown: { remarkPlugins: [remarkGfm], rehypePlugins: [ rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }], [rehypeMermaid, { strategy: 'img-svg', dark: true }], ], },
vite: { plugins: [tailwindcss()], },});Step 6: BaseLayout.astro — The Root Shell
Every page on the site renders through a single root layout. It imports the global stylesheet exactly once, sets up the HTML document structure, and provides a <slot /> for page content.
---import '@fontsource-variable/source-serif-4';import '@fontsource-variable/jetbrains-mono';import '@/styles/global.css';
interface Props { title: string; description: string; image?: string;}
const { title, description, image = '/og/default.png' } = Astro.props;const canonicalURL = new URL(Astro.url.pathname, Astro.site);---
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="canonical" href={canonicalURL} /> <title>{title}</title> <meta name="description" content={description} /> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta property="og:image" content={new URL(image, Astro.site)} /> <meta property="og:type" content="website" /> <meta name="twitter:card" content="summary_large_image" /> </head> <body class="min-h-screen bg-[var(--color-bg)] text-[var(--color-text)]"> <slot /> </body></html>Three things to note. First, fonts and CSS import once here — never in child components. Second, the Props interface gives TypeScript full type checking on every page that uses this layout. Third, <slot /> is Astro’s composition primitive. It works like React’s children but with zero JavaScript overhead.
Step 7: Content Collections and Schema
Astro 5 introduced the Content Layer API for managing structured content. Define your schema once in src/content.config.ts, and every MDX file is validated at build time.
import { defineCollection, z } from 'astro:content';import { glob } from 'astro/loaders';
const blog = defineCollection({ loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }), schema: ({ image }) => z.object({ title: z.string().max(100), description: z.string().max(300), pubDate: z.coerce.date(), updatedDate: z.coerce.date().optional(), heroImage: image(), heroAlt: z.string(), author: z.string().default('default'), tags: z.array(z.string()), category: z.enum([ 'engineering', 'architecture', 'data', 'devops', 'ai-ml', 'rust', 'frontend', 'tutorial', ]), draft: z.boolean().default(false), featured: z.boolean().default(false), series: z.object({ slug: z.string(), order: z.number(), }).optional(), toc: z.boolean().default(true), }),});
export const collections = { blog };The image() helper is critical. It tells Astro to process hero images through Sharp — generating responsive AVIF, WebP, and JPEG variants with proper srcset attributes. Raw image paths in frontmatter become fully optimized <img> elements at build time.
The z.coerce.date() call handles YAML date values, which parse as strings. Without coerce, Zod rejects pubDate: 2026-04-10 because it is a string, not a Date object.
Step 8: Build Scripts and Enforcement
A production build pipeline does more than compile code. It enforces invariants. Our package.json scripts run schema validation, island auditing, and OG image generation around the build step.
{ "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview", "prebuild": "pnpm lint:islands && pnpm check:schema", "postbuild": "node scripts/generate-og.mjs", "lint:islands": "bash scripts/check-no-client-load.sh", "check:schema": "npx tsx scripts/check-schema-parity.ts", "check": "astro check" }}The prebuild hook runs two checks before every build:
-
lint:islandsscans every.astrofile forclient:loaddirectives. If any component usesclient:load(except the banned-exception list), the build fails. This enforces the performance budget we cover in Part 2. -
check:schemavalidates that the Keystatic CMS configuration mirrors the Zod schema incontent.config.ts. Schema drift between the CMS and the content pipeline causes silent data loss.
Step 9: Cloudflare Pages Deployment
Astro’s Cloudflare adapter generates a Worker-compatible bundle. The static pages serve from Cloudflare’s CDN. Server-rendered routes (the CMS admin, AI endpoints) run on the edge.
name = "blogcraft"compatibility_date = "2025-09-01"compatibility_flags = ["nodejs_compat_v2"]
[site]bucket = "./dist"The nodejs_compat_v2 flag is required for React 19’s server-side rendering on Cloudflare’s workerd runtime. Without it, react-dom/server throws at import time.
The GitHub Actions workflow handles the full pipeline:
name: Deployon: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm
- run: pnpm install --frozen-lockfile - run: npx playwright install --with-deps chromium - run: pnpm build
- name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3 with: command: pages deploy dist --project-name=blogcraftThe Complete Integration Order
The order in which integrations register determines how content is processed. Here is the full pipeline, in execution order:
Each step depends on the output of the previous. Expressive Code wraps code fences in its custom HTML structure. MDX then compiles the document, leaving those code blocks intact. The rehype plugins operate on the compiled HTML AST. Finally, Astro’s image service optimizes any referenced images.
Key Takeaways
- Astro 5 ships zero JS by default — this is not an optimization you configure, it is the architecture. Interactive components opt in via hydration directives.
- Tailwind 4 uses
@tailwindcss/vite, not the old@astrojs/tailwindintegration. Configuration lives in CSS@themeblocks, not JavaScript. - Integration order is load-bearing. Expressive Code before MDX. Always.
- Self-hosted fonts via Fontsource eliminate FOUT and third-party requests. Import once in
BaseLayout.astro. - Content Layer schemas with Zod validate every MDX file at build time. Invalid frontmatter fails the build, not the reader.
- Prebuild enforcement (island linting, schema parity checks) catches invariant violations before they reach production.