Tutorial

Astro Deep Dive: Scaffolding a Production-Ready Astro 5 Project

Most Astro tutorials stop at pnpm create astro. By the time you need TypeScript strict mode, Tailwind 4, and a Cloudflare deployment pipeline enforcing Lighthouse 100, you are patching together Stack Overflow answers. This post gets you production-ready from the first command.

Tin Dang avatar
Tin Dang
Blueprint-style technical diagram of a modular project structure with interconnected blocks and configuration files

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.

FrameworkDefault JSContent PerformanceInteractive 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.

Terminal
pnpm create astro@latest blogcraft -- --template minimal --typescript strict
cd blogcraft

The --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.

tsconfig.json
{
"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:

.npmrc
auto-install-peers=true

This 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:

Terminal
mkdir -p src/{layouts,components/islands,content/{blog,authors,series},styles,assets/images/heroes}
mkdir -p scripts public

The 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:

Terminal window
pnpm add tailwindcss @tailwindcss/vite
Terminal window
npm install tailwindcss @tailwindcss/vite
Terminal window
yarn add tailwindcss @tailwindcss/vite

Create the global stylesheet with design tokens defined in a CSS-first @theme block:

src/styles/global.css
@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.

Terminal window
pnpm add @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-mono
Terminal window
npm install @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-mono
Terminal window
yarn add @fontsource-variable/source-serif-4 @fontsource-variable/jetbrains-mono

Satoshi 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:

src/styles/global.css (append)
@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.

astro.config.ts
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.

src/layouts/BaseLayout.astro
---
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.

src/content.config.ts
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.

package.json (scripts section)
{
"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:

  1. lint:islands scans every .astro file for client:load directives. If any component uses client:load (except the banned-exception list), the build fails. This enforces the performance budget we cover in Part 2.

  2. check:schema validates that the Keystatic CMS configuration mirrors the Zod schema in content.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.

wrangler.toml
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:

.github/workflows/deploy.yml (simplified)
name: Deploy
on:
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=blogcraft

The 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/tailwind integration. Configuration lives in CSS @theme blocks, 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.
0

Next in this series

Astro Deep Dive: The Island Architecture

Continue reading