Introduction
Astro has quickly become one of the most popular frameworks for content-driven websites. With the release of Astro 5, the framework introduces a powerful Content Layer API, enhanced Content Collections, and first-class View Transitions support — making it the ideal choice for building modern blogs.
In this tutorial, you'll build a fully functional blog from scratch using Astro 5. By the end, you'll have:
- A type-safe content system powered by Content Collections
- Markdown/MDX blog posts with frontmatter validation
- Beautiful layouts with responsive design
- Smooth page transitions using the View Transitions API
- A production deployment on Vercel or Netlify
- Node.js 18.17.1 or higher (LTS recommended)
- npm, pnpm, or yarn package manager
- A code editor (VS Code with the Astro extension recommended)
- Basic familiarity with HTML, CSS, and JavaScript
- A Vercel or Netlify account for deployment
The best part? Astro ships zero JavaScript to the client by default, resulting in incredibly fast page loads.
Prerequisites
Before we start, make sure you have:
Project Setup
Let's create a new Astro 5 project:
npm create astro@latest my-astro-blog
When prompted, select:
Navigate into your project and start the dev server:
cd my-astro-blog
npm run dev
Your site is now running at http://localhost:4321.
Project Structure
Here's the structure we'll build:
my-astro-blog/
├── src/
│ ├── content/
│ │ ├── posts/
│ │ │ ├── getting-started-with-astro.md
│ │ │ ├── why-content-collections.md
│ │ │ └── view-transitions-guide.md
│ │ └── config.ts
│ ├── layouts/
│ │ ├── BaseLayout.astro
│ │ └── PostLayout.astro
│ ├── components/
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── PostCard.astro
│ │ └── TableOfContents.astro
│ ├── pages/
│ │ ├── index.astro
│ │ ├── blog/
│ │ │ ├── index.astro
│ │ │ └── [...slug].astro
│ │ └── 404.astro
│ └── styles/
│ └── global.css
├── public/
│ └── favicon.svg
├── astro.config.mjs
├── package.json
└── tsconfig.json
Content Collections Schema
Content Collections are Astro's built-in way to manage and validate your content. In Astro 5, the Content Layer API lets you define collections from any source — local files, APIs, or databases.
Create the content configuration file:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
// Use the glob loader for local Markdown files
loader: glob({ pattern: '*/.{md,mdx}', base: './src/content/posts' }),
schema: z.object({
title: z.string().max(100),
description: z.string().max(300),
author: z.string().default('Blog Author'),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
readTime: z.number().optional(),
}),
});
export const collections = { posts };
Key Concepts
defineCollection() — creates a typed collection with validationglob() loader — Astro 5's new Content Layer loader that reads files from a directoryz (Zod) — provides schema validation; Astro will throw build errors if frontmatter doesn't matchz.coerce.date() — automatically converts date strings to Date objectsWhy This Matters
Without Content Collections, a typo in your frontmatter silently breaks your site. With them, you get:
Creating Posts
Create your first blog post:
---
title: "Getting Started with Astro 5"
description: "A beginner's guide to building websites with Astro 5, the web framework for content-driven sites."
author: "Jane Developer"
publishedAt: 2026-02-01
tags: ["astro", "web", "tutorial"]
heroImage: "/images/astro-hero.jpg"
readTime: 5
draft: false
---
# Getting Started with Astro 5
Astro is a modern web framework designed for content-driven websites. Unlike React or Vue-based frameworks that ship JavaScript bundles to the client, Astro renders everything to static HTML by default.
Why Choose Astro?
1. Zero JS by default — pages load instantly
2. Island Architecture — add interactivity only where needed
3. Content Collections — type-safe content management
4. Framework agnostic — use React, Vue, Svelte, or none
Your First Component
astro
---
// This runs at build time, not in the browser
const greeting = "Hello, Astro!";
---
{greeting}
This page has zero JavaScript.
Astro components use a .astro extension and have a frontmatter fence (---) for server-side logic.
Create a second post:
---
title: "Understanding Content Collections"
description: "Deep dive into Astro 5's Content Collections and the Content Layer API."
author: "Jane Developer"
publishedAt: 2026-02-05
tags: ["astro", "content", "typescript"]
readTime: 7
draft: false
---
# Understanding Content Collections
Content Collections bring type safety to your Markdown content...
Layouts and Components
Base Layout
Create a shared layout that every page uses:
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description = 'A modern blog built with Astro 5' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title} | My Astro Blog</title>
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
<style is:global>
:root {
--color-bg: #0d1117;
--color-text: #c9d1d9;
--color-primary: #58a6ff;
--color-surface: #161b22;
--color-border: #30363d;
--max-width: 768px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.7;
}
main {
max-width: var(--max-width);
margin: 0 auto;
padding: 2rem 1rem;
}
</style>
Header Component
---
// src/components/Header.astro
const navLinks = [
{ href: '/', label: 'Home' },
{ href: '/blog', label: 'Blog' },
];
---
<header>
<nav>
<a href="/" class="logo">🚀 AstroBlog</a>
<ul>
{navLinks.map(link => (
<li><a href={link.href}>{link.label}</a></li>
))}
</ul>
</nav>
</header>
<style>
header {
border-bottom: 1px solid var(--color-border);
padding: 1rem;
}
nav {
max-width: var(--max-width);
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.25rem;
font-weight: bold;
text-decoration: none;
color: var(--color-primary);
}
ul {
display: flex;
gap: 1.5rem;
list-style: none;
}
a {
color: var(--color-text);
text-decoration: none;
}
a:hover {
color: var(--color-primary);
}
</style>
Post Card Component
---
// src/components/PostCard.astro
interface Props {
title: string;
description: string;
publishedAt: Date;
tags: string[];
slug: string;
readTime?: number;
}
const { title, description, publishedAt, tags, slug, readTime } = Astro.props;
const formattedDate = publishedAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<article class="post-card">
<a href={/blog/${slug}}>
<h2>{title}</h2>
<p class="meta">
<time datetime={publishedAt.toISOString()}>{formattedDate}</time>
{readTime && <span> · {readTime} min read</span>}
</p>
<p class="description">{description}</p>
<div class="tags">
{tags.map(tag => <span class="tag">#{tag}</span>)}
</div>
</a>
</article>
<style>
.post-card {
padding: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
transition: border-color 0.2s;
}
.post-card:hover {
border-color: var(--color-primary);
}
a {
text-decoration: none;
color: inherit;
}
h2 {
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.meta {
font-size: 0.875rem;
color: #8b949e;
margin-bottom: 0.75rem;
}
.description {
margin-bottom: 1rem;
}
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.75rem;
color: var(--color-primary);
background: rgba(88, 166, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
</style>
Post Layout
---
// src/layouts/PostLayout.astro
import BaseLayout from './BaseLayout.astro';
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const { title, description, publishedAt, author, tags, readTime } = post.data;
const formattedDate = publishedAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout title={title} description={description}>
<article class="post">
<header>
<h1>{title}</h1>
<p class="meta">
By {author} · <time datetime={publishedAt.toISOString()}>{formattedDate}</time>
{readTime && · ${readTime} min read}
</p>
<div class="tags">
{tags.map(tag => <span class="tag">#{tag}</span>)}
</div>
</header>
<div class="content">
<slot />
</div>
</article>
</BaseLayout>
<style>
.post header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
h1 {
font-size: 2.25rem;
line-height: 1.3;
margin-bottom: 0.75rem;
}
.meta {
color: #8b949e;
margin-bottom: 1rem;
}
.tags {
display: flex;
gap: 0.5rem;
}
.tag {
font-size: 0.8rem;
color: var(--color-primary);
background: rgba(88, 166, 255, 0.1);
padding: 0.2rem 0.6rem;
border-radius: 4px;
}
.content {
font-size: 1.05rem;
}
.content :global(h2) {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.content :global(pre) {
background: var(--color-surface);
padding: 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--color-border);
}
.content :global(code) {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
}
</style>
Blog Pages
Blog Index Page
---
// src/pages/blog/index.astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('posts', ({ data }) => !data.draft);
// Sort by date, newest first
const sortedPosts = posts.sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()
);
---
<BaseLayout title="Blog">
<h1>Blog Posts</h1>
<p class="subtitle">Thoughts on web development, Astro, and more.</p>
<div class="posts-grid">
{sortedPosts.map(post => (
<PostCard
title={post.data.title}
description={post.data.description}
publishedAt={post.data.publishedAt}
tags={post.data.tags}
slug={post.id}
readTime={post.data.readTime}
/>
))}
</div>
</BaseLayout>
<style>
h1 { margin-bottom: 0.5rem; }
.subtitle {
color: #8b949e;
margin-bottom: 2rem;
}
.posts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
</style>
Dynamic Post Page
---
// src/pages/blog/[...slug].astro
import PostLayout from '../../layouts/PostLayout.astro';
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout post={post}>
<Content />
</PostLayout>
Note that in Astro 5, we use the standalone render() function imported from astro:content instead of the old post.render() method.
View Transitions
Astro has built-in support for the View Transitions API, enabling smooth, SPA-like page transitions without any client-side framework.
Enabling View Transitions
Update your BaseLayout.astro:
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
interface Props {
title: string;
description?: string;
}
const { title, description = 'A modern blog built with Astro 5' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title} | My Astro Blog</title>
<ViewTransitions />
</head>
<body>
<!-- ... rest of layout -->
</body>
</html>
Just by adding , every page navigation now gets a smooth crossfade animation!
Custom Transition Animations
You can customize transitions per element using transition:animate:
---
// src/components/PostCard.astro (updated)
---
<article class="post-card" transition:animate="slide">
<!-- card content -->
</article>
Astro provides built-in animations:
| Animation | Description |
|-----------|-------------|
| fade | Crossfade (default) |
| slide | Slide in from the side |
| none | Disable transition |
| initial | Use CSS without Astro defaults |
Persistent Elements with transition:name
To create a morphing effect where an element smoothly transforms between pages:
---
// In PostCard.astro
---
<h2 transition:name={title-${slug}}>{title}</h2>
---
// In PostLayout.astro
---
<h1 transition:name={title-${post.id}}>{title}</h1>
Now when clicking a post card, the title smoothly animates from the card position to the post header. This creates a delightful, app-like experience with zero JavaScript overhead.
Handling Scripts During Transitions
Since View Transitions swap page content without full reloads, scripts need special handling:
<script>
// This runs once on initial load AND after each transition
document.addEventListener('astro:page-load', () => {
console.log('Page loaded or transitioned!');
// Initialize components, attach event listeners, etc.
});
</script>
Adding an RSS Feed
Install the RSS integration:
npx astro add @astrojs/rss
Create the RSS endpoint:
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return rss({
title: 'My Astro Blog',
description: 'A blog about web development',
site: context.site!,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.publishedAt,
description: post.data.description,
link: /blog/${post.id}/,
})),
customData: '<language>en-us</language>',
});
}
Update astro.config.mjs to include your site URL:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://my-astro-blog.vercel.app',
});
Deployment
Astro makes deployment incredibly simple. Let's cover both Vercel and Netlify.
Deploy to Vercel
Install the Vercel adapter:
npx astro add vercel
This automatically updates your astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
export default defineConfig({
site: 'https://my-astro-blog.vercel.app',
output: 'static',
adapter: vercel(),
});
Deploy:
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
Or connect your GitHub repo to Vercel for automatic deployments on every push.
Deploy to Netlify
Install the Netlify adapter:
npx astro add netlify
// astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
export default defineConfig({
site: 'https://my-astro-blog.netlify.app',
output: 'static',
adapter: netlify(),
});
Deploy:
# Install Netlify CLI
npm i -g netlify-cli
# Build and deploy
npm run build
netlify deploy --prod --dir=dist
Build Output
For a static blog, Astro generates pure HTML/CSS files:
$ npm run build
generating static routes
▶ src/pages/index.astro → /index.html (+12ms)
▶ src/pages/blog/index.astro → /blog/index.html (+8ms)
▶ src/pages/blog/[...slug].astro → /blog/getting-started-with-astro/index.html (+5ms)
▶ src/pages/blog/[...slug].astro → /blog/why-content-collections/index.html (+4ms)
▶ src/pages/rss.xml.ts → /rss.xml (+3ms)
Completed in 1.2s
dist/ 42.5 kB
42.5 kB for an entire blog — no JavaScript bundles, no hydration overhead.
Performance Optimization
Astro is fast by default, but here are tips to make it even faster:
Image Optimization
Use Astro's built-in component:
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Image
src={heroImage}
alt="Blog hero image"
width={800}
height={400}
format="avif"
/>
This automatically generates optimized images in modern formats with proper srcset attributes.
Prefetching
Astro automatically prefetches links when they become visible in the viewport (when View Transitions are enabled). You can customize this:
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true, // Prefetch all links
defaultStrategy: 'hover', // Or 'viewport', 'hover', 'load'
},
});
Conclusion
You've built a modern, fast blog with Astro 5 featuring:
Astro 5's philosophy of shipping zero JavaScript by default makes it perfect for content-heavy sites. The Content Collections system ensures your content stays valid as your blog grows, and View Transitions give users a premium browsing experience.
Next Steps
npx astro add mdx) for interactive components in postsnpx astro add tailwind) for utility-first stylingThe complete source code for this tutorial is available on GitHub. Happy blogging! 🚀