A complete, practical guide to building a fast, production-ready blog with Astro from project setup and content collections to dynamic routes, SEO, and deployment.
If you want to build a blog that loads fast, ranks well in search engines, and is easy to maintain, Astro is one of the best tools available right now.
Astro is a modern static site builder with a core philosophy different from most JavaScript frameworks: it ships zero JavaScript to the browser by default, supports Markdown and MDX out of the box, and lets you use React, Vue, or Svelte, or none of them, exactly where you need interactivity. For content-heavy sites like blogs, documentation, and marketing pages, that trade-off is ideal.
In this tutorial, you will go from zero to a production-ready Astro blog. You will learn how to set up the project, understand Astro’s file-based routing, build a type-safe content system with Content Collections, create the blog index and individual post pages, wire up Search Engine Optimization (SEO), add dark mode without a flash, and deploy. Every pattern here comes from a real-world codebase, so this is not theoretical scaffolding.
Table of Contents
Why Astro for a Blog?
Before writing a single line of code, it is worth understanding why Astro is a particularly good fit for content-heavy sites.
Most JavaScript frameworks — React, Next.js, Vue — are built around interactivity. They ship a JavaScript runtime to the browser, hydrate the Document Object Model (DOM) on the client side, and manage your UI as a stateful application. That is the right model for a dashboard or a SaaS app. But a blog post does not need any of that. It is static text that never changes after it is published.
Astro’s answer to this is Islands Architecture. Everything renders to static HTML at build time. The page ships zero JavaScript by default. If a specific component genuinely needs interactivity — a theme toggle, a comment section, a search bar — you opt into client-side rendering with a client:* directive. That component becomes an “island” that hydrates independently while everything else stays as lightweight HTML.
The practical results for your blog are:
- Pages that score near-perfect on Core Web Vitals
- Excellent SEO because search engine crawlers receive real HTML, not a JavaScript shell
- Near-instant load times, even on slow mobile connections
- Dead-simple deployment — the output is just static files
For a blog, there is genuinely no better default starting point.
Prerequisites
To follow this tutorial, you will need:
- Node.js v18 or higher installed on your machine (download here)
- Basic familiarity with HTML, CSS, and JavaScript/TypeScript
- Some experience with Markdown — that is how you will write blog posts
- A code editor like VS Code
You do not need to know React or any other UI framework. Astro works perfectly well with its own .astro component format, which is straightforward to pick up.
How to Set Up Your Astro Project
You can use npm, pnpm, or yarn; any of them will work. This tutorial uses pnpm, which is recommended for better performance and disk efficiency.
Open your terminal and run:
pnpm create astro@latest my-blog
The Astro Command Line Interface (CLI) will walk you through a few prompts. When asked:
- Choose “A blog” as the starter template — it gives you a working foundation
- Say yes to installing dependencies
- Say yes to initializing a git repository
Once setup completes, navigate into your project and start the development server:
cd my-blog
pnpm dev
Open http://localhost:4321 and you will see your Astro site running. Now install the integrations you will need for a production blog:
pnpm astro add react mdx sitemap
pnpm add @tailwindcss/vite @tailwindcss/typography

Here is what each integration does:
reactenables React components in.astrofiles (useful for UI libraries like shadcn/ui)mdxallows.mdxfiles in your content collection — Markdown with embedded React componentssitemapauto-generates asitemap.xmlat build time, which search engines expect@tailwindcss/vitehooks Tailwind CSS v4 into Astro’s Vite build pipeline
Want to skip the setup entirely? The Ink Blog Template is a free, open-source Astro blog template with all of this already wired up. Check the source code.
How to Understand the Project Structure
After setup, your project looks something like this:
my-blog/
├── public/ # Static assets served directly at root URL
│ └── images/blog-post/ # Hero images for blog posts
├── src/
│ ├── consts.ts # Site-wide configuration (title, description, and so on)
│ ├── content.config.ts # Blog collection schema definition
│ ├── content/
│ │ └── blog/ # Your .md/.mdx post files live here
│ ├── pages/
│ │ ├── index.astro # Homepage
│ │ ├── rss.xml.js # Auto-generated RSS feed
│ │ └── blog/
│ │ └── [slug].astro # Dynamic route for every post
│ ├── layouts/
│ │ ├── Layout.astro # Main page shell
│ │ └── HeadSeo.astro # SEO meta tags
│ ├── components/
│ │ ├── blocks/ # Section-level components (hero, blog grid, CTA)
│ │ ├── layout/ # Header, footer, theme toggle
│ │ └── ui/ # Reusable UI primitives (shadcn/ui)
│ ├── utils/
│ │ └── blog.ts # Helper functions for posts
│ └── styles/
│ └── global.css
├── astro.config.mjs
└── package.json
Here are the key folders you need to understand:
src/pages/ is where your routes live. A file at src/pages/about.astro automatically becomes /about. There is no router to configure — Astro’s routing is entirely file-based.
src/content/ is where your blog posts live. Astro’s Content Collections Application Programming Interface (API) reads from this folder, validates frontmatter against a schema, and gives you a type-safe way to query all your posts.
src/layouts/ holds page shell templates. Instead of repeating <html>, <head>, and <nav> in every page file, you define them once in a layout and reuse them everywhere.
public/ is for static assets. Images referenced in your frontmatter (like hero images) should live here, not in src/. Everything in public/ is served at the root URL as-is.
How to Centralize Site Configuration
One of the most useful habits in an Astro project is keeping your site metadata in a single constants file. When you update your site title, change your domain, or adjust keywords, you change it in one place rather than hunting through dozens of files.
Create src/consts.ts:
// src/consts.ts
export const SITE_TITLE = 'My Blog'
export const SITE_DESCRIPTION = 'A fast, modern blog built with Astro.'
export const SITE_URL = '<https://yourblog.com/>'
export const SITE_METADATA = {
title: {
default: 'My Blog'
},
description: 'A fast, modern blog built with Astro.',
keywords: ['Astro', 'blog', 'web development', 'TypeScript'],
authors: [{ name: 'Your Name', url: SITE_URL }],
robots: {
index: true,
follow: true
},
openGraph: {
siteName: 'My Blog',
images: [{ url: '/og-image.png' }]
}
}
Your astro.config.mjs should also reference your production URL:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite'
import sitemap from '@astrojs/sitemap'
import mdx from '@astrojs/mdx'
import react from '@astrojs/react'
export default defineConfig({
site: '<https://yourblog.com/>', // Required for sitemap and OG image URLs
integrations: [
react(),
mdx(),
sitemap({
serialize(item) {
if (item.url.includes('/blog/')) {
item.changefreq = 'weekly'
item.priority = 0.8
}
return item
}
})
],
vite: {
plugins: [tailwindcss()]
}
})
The site property is important — without it, the sitemap generator and Open Graph image URLs will not resolve correctly.
How to Define Your Content Collection Schema
Astro’s Content Collections API is how you manage structured blog content. Instead of manually reading files and hoping the frontmatter is correct, you define a schema with Zod — a TypeScript validation library — and Astro validates every post against it at build time.
If a post is missing a required field, the build fails immediately with a clear error. That is much better than silently rendering a broken page.
Create src/content.config.ts:
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
id: z.number(),
slug: z.string(),
title: z.string(),
description: z.string(),
imageUrl: z.string().optional(),
imageAlt: z.string().optional(),
pubDate: z.string(),
author: z.string().default('Your Name'),
avatarUrl: z.string().optional(),
category: z.string().default('General'),
readTime: z.number().optional(),
featured: z.boolean().default(false)
})
})
export const collections = { blog }
Here are a few notes on the schema design:
- Fields without
.optional()or.default()are required. Missing them causes a build error. imageUrl,imageAlt,avatarUrl, andreadTimeare optional because not every post needs a hero image or a custom read time.author,category, andfeaturedhave sensible defaults so you do not have to repeat them in every post’s frontmatter.- The
globloader with*/*.{md,mdx}picks up both plain Markdown and MDX files. MDX lets you embed React components directly in your posts.
If you want to add tags later, it is as simple as adding one line:
tags: z.array(z.string()).optional()
How to Write Your First Blog Post
Blog posts are Markdown (.md) or MDX (.mdx) files placed in src/content/blog/. The frontmatter at the top must match your schema exactly.
Create src/content/blog/my-first-post.mdx:
---
id: 1
slug: 'my-first-post'
title: 'My First Blog Post'
description: 'Getting started with Astro and publishing my first post.'
imageUrl: '/images/blog-post/post-1.png'
imageAlt: 'A desk with a laptop and coffee'
pubDate: 'February 27, 2026'
category: 'Web Development'
author: 'Jane Doe'
avatarUrl: '/images/avatars/1.png'
readTime: 4
featured: false
---
## Getting Started
Welcome to my blog. I built this with Astro, and the experience has been genuinely enjoyable.
## What I Learned
Astro renders everything to static HTML at build time, which means pages load
instantly and there is no JavaScript bloat. For a blog, that is exactly what
you want.
The block between the --- dashes at the top of the file is called frontmatter. It is YAML-formatted metadata about the post. Astro reads this metadata and makes it available to your page templates as typed data.
MDX is particularly powerful because you can import and use React components directly inside your Markdown:
import Callout from '@/components/Callout'
Regular markdown prose here.
<Callout type="tip">
This is a custom React component rendered inside a blog post.
You can use this for tips, warnings, or code walkthroughs.
</Callout>
Back to regular markdown.
How to Build the Blog Index Page
The blog index page lists all your posts. Open or create src/pages/blog/index.astro:
---
import { getCollection } from 'astro:content'
import Layout from '@/layouts/Layout.astro'
const allPosts = await getCollection('blog')
const posts = allPosts
.map(post => ({
id: post.data.id,
slug: post.data.slug,
title: post.data.title,
description: post.data.description,
imageUrl: post.data.imageUrl,
imageAlt: post.data.imageAlt,
pubDate: post.data.pubDate,
author: post.data.author,
category: post.data.category,
readTime: post.data.readTime
}))
.sort((a, b) => b.pubDate.localeCompare(a.pubDate))
---
<Layout title='Blog' description='All posts'>
<main class='mx-auto max-w-4xl px-4 py-12'>
<h1 class='mb-8 text-4xl font-bold'>Blog</h1>
<ul class='space-y-8'>
{
posts.map(post => (
<li>
<a href={`/blog/${post.slug}`} class='group block'>
{post.imageUrl && <img src={post.imageUrl} alt={post.imageAlt || post.title} class='mb-4 rounded-lg' />}
<p class='text-muted-foreground text-sm'>
{post.category} · {post.readTime} min read
</p>
<h2 class='text-2xl font-semibold group-hover:underline'>{post.title}</h2>
<p class='text-muted-foreground mt-1'>{post.description}</p>
<p class='text-muted-foreground mt-2 text-sm'>
{post.pubDate} — {post.author}
</p>
</a>
</li>
))
}
</ul>
</main>
</Layout>
Here is what each part does:
- The frontmatter block (between
--) runs at build time. This is where you fetch data and set up variables. getCollection('blog')fetches all posts that match your schema and returns them as a fully-typed array. Your editor will autocompletepost.data.title,post.data.category, and every other field you defined..sort(...)orders posts newest-first by comparing thepubDatestring values. Because your dates follow a consistent format, this works correctly.- The template is standard HTML mixed with JSX-style expressions — the same pattern you would use in React.
In a real project, you would extract the post card into a separate component to keep this page clean.
How to Build Individual Post Pages
Every blog post needs its own URL. Astro handles dynamic routes with a special file naming convention: any file with [paramName] in its name becomes a dynamic route.
Create src/pages/blog/[slug].astro:
---
import { getCollection, render } from 'astro:content'
import Layout from '@/layouts/Layout.astro'
import { getRelatedPosts, getPostNavigation, calculateReadTime } from '@/utils/blog'
export async function getStaticPaths() {
const blogEntries = await getCollection('blog')
return blogEntries.map(entry => ({
params: { slug: entry.id },
props: { entry }
}))
}
const { entry } = Astro.props
const { Content, headings } = await render(entry)
const allPosts = await getCollection('blog')
const relatedPosts = getRelatedPosts(allPosts, entry.id, entry.data.category, 3)
const { previous, next } = getPostNavigation(allPosts, entry.id)
const readTime = entry.data.readTime || calculateReadTime(entry.body)
---
<Layout title={entry.data.title} description={entry.data.description}>
<article class='mx-auto max-w-3xl px-4 py-12'>
{
entry.data.imageUrl && (
<img src={entry.data.imageUrl} alt={entry.data.imageAlt || entry.data.title} class='mb-8 w-full rounded-xl' />
)
}
<header class='mb-8'>
<p class='text-muted-foreground mb-2 text-sm'>
{entry.data.category} · {readTime} min read
</p>
<h1 class='text-4xl font-bold'>{entry.data.title}</h1>
<p class='text-muted-foreground mt-4'>{entry.data.description}</p>
<p class='text-muted-foreground mt-2 text-sm'>
{entry.data.pubDate} — {entry.data.author}
</p>
</header>
<div class='prose dark:prose-invert max-w-none'>
<Content />
</div>
<nav class='mt-12 flex justify-between gap-4'>
{
previous && (
<a href={`/blog/${previous.data.slug}`} class='text-sm hover:underline'>
← {previous.data.title}
</a>
)
}
{
next && (
<a href={`/blog/${next.data.slug}`} class='ml-auto text-sm hover:underline'>
{next.data.title} →
</a>
)
}
</nav>
</article>
</Layout>
Here are the critical pieces:
getStaticPaths() is mandatory for dynamic routes. Astro calls this function at build time to know which pages to generate. You return an array — one entry per blog post — with the slug as the URL parameter and the post entry as a prop. If you forget this function, the build will throw an error.
render(entry) transforms the Markdown or MDX content into two things: a Content component (the compiled body, ready to drop into your template) and a headings array (every heading extracted from the post, perfect for building a table of contents).
<Content /> is a component you render wherever you want the post body to appear. Wrap it in a prose class from @tailwindcss/typography and you get well-formatted body text with zero additional CSS.
How to Add a Reusable Layout
Layouts prevent you from repeating <html>, <head>, navigation, and footer in every page file. Create src/layouts/Layout.astro:
---
import HeadSeo from './HeadSeo.astro'
import Header from '@/components/layout/header'
import Footer from '@/components/layout/footer'
interface Props {
title?: string
description?: string
image?: string
}
const { title, description, image } = Astro.props
---
<!doctype html>
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<HeadSeo title={title} description={description} image={image} />
<script is:inline>
;(function () {
const stored = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark')
}
})()
</script>
</head>
<body class='bg-background text-foreground min-h-screen'>
<Header client:load />
<slot />
<Footer />
</body>
</html>
The <slot /> element is where each page’s content gets injected. When you wrap a page in this layout, everything inside the layout tags replaces <slot />.
The inline script handles dark mode — more on that in the next section.
How to Handle SEO and Structured Data
Create src/layouts/HeadSeo.astro to centralize all your SEO tags:
---
import { SITE_METADATA, SITE_URL } from '@/consts'
interface Props {
title?: string
description?: string
image?: string
type?: string
schema?: object
}
const { title, description, image, type = 'website', schema } = Astro.props
const finalTitle = title ? `${title} — ${SITE_METADATA.title.default}` : SITE_METADATA.title.default
const finalDescription = description || SITE_METADATA.description
const finalImage = image || SITE_METADATA.openGraph.images[0].url
---
<title>{finalTitle}</title>
<meta name='description' content={finalDescription} />
<meta name='robots' content='index, follow' />
<!-- Open Graph -->
<meta property='og:title' content={finalTitle} />
<meta property='og:description' content={finalDescription} />
<meta property='og:type' content={type} />
<meta property='og:image' content={new URL(finalImage, SITE_URL)} />
<!-- Twitter Card -->
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={finalTitle} />
<meta name='twitter:description' content={finalDescription} />
<meta name='twitter:image' content={new URL(finalImage, SITE_URL)} />
<!-- RSS feed discovery -->
<link rel='alternate' type='application/rss+xml' title='RSS Feed' href='/rss.xml' />
<!-- JSON-LD structured data (passed per-page) -->
{schema && <script type='application/ld+json' set:html={JSON.stringify(schema)} />}
Then in your blog post page, inject a BlogPosting schema for rich search results:
---
const schema = {
'@context': '<https://schema.org>',
'@type': 'BlogPosting',
headline: entry.data.title,
description: entry.data.description,
image: entry.data.imageUrl,
author: {
'@type': 'Person',
name: entry.data.author
},
datePublished: entry.data.pubDate
}
---
<Layout title={entry.data.title} description={entry.data.description} schema={schema}> ... </Layout>
This structured data helps search engines understand your content. It can also trigger rich results — article previews with author, date, and image that appear directly in Google’s search results.
How to Add Dark Mode Without a Flash
Getting dark mode right is a well-known front-end challenge. The problem is that if you read the theme preference from localStorage inside a React component, the component renders after the browser has already painted the page — causing a visible flash from light to dark.
The fix is an inline script that runs before any painting happens:
<script is:inline>
;(function () {
const stored = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark')
}
})()
</script>
The is:inline attribute tells Astro not to process or bundle this script. It is dropped into the HTML exactly as written, and the browser executes it synchronously before painting. No flash.
The ThemeToggle React component then handles user-initiated changes:
// src/components/layout/theme-toggle.tsx
import * as React from 'react'
export function ThemeToggle() {
const [theme, setTheme] = React.useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
}
return 'light'
})
React.useEffect(() => {
document.documentElement.classList[theme === 'dark' ? 'add' : 'remove']('dark')
localStorage.setItem('theme', theme)
}, [theme])
return (
<button onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))} aria-label='Toggle theme'>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
)
}
The useState initializer reads from localStorage on first render, keeping the component in sync with the DOM state set by the inline script.
How to Write Blog Utility Functions
Keeping logic out of your page templates makes them easier to read and easier to test. Create src/utils/blog.ts:
import type { CollectionEntry } from 'astro:content'
// Estimate read time based on average reading speed
export function calculateReadTime(text: string | undefined): number {
if (!text) return 1
const wordsPerMinute = 200
const words = text.trim().split(/\\s+/).length
return Math.ceil(words / wordsPerMinute)
}
// Get posts in the same category, falling back to others if needed
export function getRelatedPosts(
posts: CollectionEntry<'blog'>[],
currentSlug: string,
currentCategory: string,
limit = 3
): CollectionEntry<'blog'>[] {
const sameCategory = posts.filter(p => p.data.category === currentCategory && p.id !== currentSlug)
if (sameCategory.length >= limit) {
return sameCategory.slice(0, limit)
}
const others = posts.filter(p => p.data.category !== currentCategory && p.id !== currentSlug)
return [...sameCategory, ...others].slice(0, limit)
}
// Get previous and next posts for navigation links
export function getPostNavigation(posts: CollectionEntry<'blog'>[], currentSlug: string) {
const sorted = [...posts].sort((a, b) => a.data.id - b.data.id)
const index = sorted.findIndex(p => p.id === currentSlug)
return {
previous: index > 0 ? sorted[index - 1] : null,
next: index < sorted.length - 1 ? sorted[index + 1] : null
}
}
calculateReadTime uses the classic 200 words-per-minute heuristic. It is called on the post page when the frontmatter does not include an explicit readTime — so posts get a read time estimate automatically. You can override it manually for posts with a lot of code or images.
getRelatedPosts prioritizes same-category posts. If there are not enough, it backfills from other categories. That fallback prevents an empty “Related Posts” section for new blogs with limited content.
getPostNavigation sorts posts by id (which maps to publication order) and returns the adjacent post in each direction.
How to Add a Table of Contents Component
A table of contents helps readers navigate longer posts. The render() function already returns a headings array that contains every heading from the post content — you do not need any plugins or regex parsing.
Create src/components/astro/DynamicToc.astro:
---
interface Heading {
depth: number
slug: string
text: string
}
interface Props {
headings?: Heading[]
}
const { headings = [] } = Astro.props
// Only include h2 and h3
const tocHeadings = headings.filter(h => h.depth === 2 || h.depth === 3)
// Group h3s under their parent h2
type GroupedHeading = Heading & { children: Heading[] }
const grouped: GroupedHeading[] = []
let current: GroupedHeading | null = null
for (const heading of tocHeadings) {
if (heading.depth === 2) {
current = { ...heading, children: [] }
grouped.push(current)
} else if (heading.depth === 3 && current) {
current.children.push(heading)
}
}
---
<nav class='sticky top-24 text-sm'>
<p class='text-foreground mb-3 font-semibold'>On This Page</p>
<ul class='space-y-1'>
{
grouped.map(h2 => (
<li>
<a href={`#${h2.slug}`} class='text-muted-foreground hover:text-foreground transition-colors'>
{h2.text}
</a>
{h2.children.length > 0 && (
<ul class='mt-1 space-y-1 pl-3'>
{h2.children.map(h3 => (
<li>
<a href={`#${h3.slug}`} class='text-muted-foreground hover:text-foreground transition-colors'>
{h3.text}
</a>
</li>
))}
</ul>
)}
</li>
))
}
</ul>
</nav>
This component is entirely server-rendered — no JavaScript in the browser. The sticky top-24 positioning keeps it visible as the user scrolls.
If you want active-link highlighting (the current heading highlighted as the user scrolls), you can add a small IntersectionObserver script inside the same component:
<script>
const headings = document.querySelectorAll('article h2, article h3')
const links = document.querySelectorAll('nav a')
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(link => link.classList.remove('text-foreground', 'font-medium'))
const active = document.querySelector(`nav a[href="#${entry.target.id}"]`)
active?.classList.add('text-foreground', 'font-medium')
}
})
},
{ rootMargin: '0px 0px -60% 0px' }
)
headings.forEach(h => observer.observe(h))
</script>
How to Generate an RSS Feed
An RSS (Really Simple Syndication) feed lets readers subscribe to your blog using feed reader applications. Astro has a first-class @astrojs/rss package that makes this about 15 lines of code.
Create src/pages/rss.xml.js:
import rss from '@astrojs/rss'
import { getCollection } from 'astro:content'
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts'
export async function GET(context) {
const posts = await getCollection('blog')
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site,
items: posts.map(post => ({
title: post.data.title,
description: post.data.description,
pubDate: new Date(post.data.pubDate),
link: `/blog/${post.data.slug}/`
})),
customData: `<language>en-us</language>`
})
}
Because this file is in src/pages/, Astro treats it as a route and generates /rss.xml at build time. Add a <link> tag to your layouts <head> so browsers can discover the feed automatically:
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss.xml" />
Common Mistakes and How to Avoid Them
Frontmatter date format inconsistency
If your date sort behaves unexpectedly, make sure all your dates follow the same format. For example, 'February 27, 2026' strings work well for human readability in templates. If you need precise date comparisons, parse them before sorting:
.sort((a, b) =>
new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
)
getStaticPaths() is non-optional
Any file using square bracket routing — for example, [slug].astro — must export a getStaticPaths() function. This is how Astro knows which pages to generate. If you forget it, the build will throw a clear error. It is easy to miss if you are copying a pattern from somewhere else.
Images must live in public/, not src/
Images referenced in frontmatter — like imageUrl: '/images/blog-post/post-1.png' — need to be in the public/ folder. Files in public/ are served as-is at the root URL. If you put them in src/assets/, the path will not resolve correctly at runtime.
New required schema fields break existing posts
If you add a new required field to your collection schema, every existing post that does not have it will fail validation. Always mark new fields .optional() unless you are also updating all existing posts at the same time.
Build errors that do not appear in development
Astro’s development server is more permissive than the production build. Always run pnpm build before deploying to catch schema validation errors, missing getStaticPaths() calls, and broken imports.
Do not use client:load everywhere
The client:load directive makes a component hydrate immediately when the page loads. For interactive components that are not visible above the fold, client:visible is a better choice — it waits until the component scrolls into view. Overusing client:load defeats part of the performance advantage that makes Astro attractive.
How to Deploy Your Blog
The production build outputs a fully static site in the dist/ folder:
# Build for production
pnpm build
# Preview locally before deploying
pnpm preview
The output is just HTML, CSS, and some JavaScript — flat files you can host anywhere.
Vercel is the zero-effort option. Connect your GitHub repo, set the build command to pnpm build, and set the output directory to dist. Pushes to the main branch deploy automatically.
Netlify works identically. Connect your repo, specify the same build command and output directory, and you are done.
Cloudflare Pages is worth considering for global performance. Your static assets are served from Cloudflare’s edge network, which means fast load times regardless of where your readers are.
For blogs that need more control than static hosting, especially WordPress, WooCommerce, or business sites with growing traffic, a managed cloud VPS can be a better long-term option. Providers like ScalaHosting managed cloud VPS offer scalable resources, NVMe storage, LiteSpeed support, free website migration, 24/7 technical support, and built-in security through SShield, making it easier to host fast content-heavy websites without managing server infrastructure yourself.
GitHub Pages is the free option if cost matters. Astro has a GitHub Pages deployment guide with a ready-made GitHub Actions workflow.
What You Get Out of the Box
Here is a summary of everything a well-built Astro blog gives you:
| Feature | How It’s Implemented |
|---|---|
| Blog with MDX support | @astrojs/mdx + Content Collections |
| Type-safe frontmatter | Zod schema in content.config.ts |
| Dynamic routing | [slug].astro + getStaticPaths() |
| SEO meta tags + Open Graph | HeadSeo.astro |
| JSON-LD structured data | Per-page schema prop |
| Auto-generated sitemap | @astrojs/sitemap |
| RSS feed | rss.xml.js |
| Dark mode (no flash) | Inline script + React toggle |
| Related posts | getRelatedPosts() utility |
| Post navigation (prev/next) | getPostNavigation() utility |
| Table of contents | DynamicToc.astro |
| Read time estimate | calculateReadTime() utility |
| Tailwind CSS v4 | @tailwindcss/vite |
| shadcn/ui components | Optional, pre-installed in Ink template |
All of this is configuration, not code you have to write from scratch. Once the structure is in place, adding a new post is as simple as creating a new .mdx file.
Conclusion
In this tutorial, you learned how to build a production-ready blog with Astro from scratch.
You set up the project and installed the required integrations. You defined a type-safe content schema with Zod and wrote your first MDX blog post. You built a blog index page using getCollection(), created dynamic post routes with getStaticPaths(), and added a reusable layout component. You wired up SEO meta tags and JSON-LD structured data, implemented flash-free dark mode, and extracted reusable utility functions for read time, related posts, and navigation. You also added a sticky table of contents, generated an RSS feed, and learned how to deploy to Vercel, Netlify, Cloudflare Pages, or GitHub Pages.
Content Collections give you type safety that catches mistakes at build time rather than in production. MDX lets you write rich content without fighting abstractions. Islands Architecture keeps your site fast by default. And the deployment story — push to GitHub, done — removes the last friction from publishing.
Here are some good next steps to keep building on what you have:
- Add full-text search with Pagefind — it integrates with Astro in about 10 minutes and runs completely client-side without a backend
- Set up View Transitions for smooth page animations that make navigation feel native
- Explore Astro’s image optimization with the
<Image />component for automatic WebP conversion and lazy loading - Add a reading progress bar using a
scrollevent listener that updates a CSS custom property
The Astro documentation is among the best in the ecosystem — clear, well-organized, and actively maintained.
Source Code
The complete template that this guide is based on:
- Download: Ink Blog Template (Free) – production-ready, deploy in minutes
- GitHub: shadcn-astro-ink-landing-page-free – the free, open-source Astro + Tailwind + shadcn/ui blog template
- More templates: shadcnstudio.com/templates – Astro templates for blogs, landing pages, portfolios, and more
If you want to skip the setup and focus on writing, start from the template. If you want to understand how everything works first, this guide has you covered.
Happy building.