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

  • 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
  • Project Setup

    Let's create a new Astro 5 project:

    npm create astro@latest my-astro-blog
    

    When prompted, select:

  • How would you like to start your new project? → Empty
  • Do you plan to write TypeScript? → Yes
  • How strict should TypeScript be? → Strict
  • Install dependencies? → Yes
  • Initialize a git repository? → Yes
  • 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 validation
  • glob() loader — Astro 5's new Content Layer loader that reads files from a directory
  • z (Zod) — provides schema validation; Astro will throw build errors if frontmatter doesn't match
  • z.coerce.date() — automatically converts date strings to Date objects
  • Why This Matters

    Without Content Collections, a typo in your frontmatter silently breaks your site. With them, you get:

  • Type safety — TypeScript knows the shape of every post
  • Build-time validation — errors caught before deployment
  • Auto-generated types — full IntelliSense in your editor
  • 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:

  • Content Collections with type-safe schemas and the new Content Layer API
  • Markdown posts with frontmatter validation
  • Responsive layouts with scoped CSS
  • View Transitions for smooth page navigation
  • RSS feed for subscribers
  • Production deployment on Vercel or Netlify
  • 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

  • Add MDX support (npx astro add mdx) for interactive components in posts
  • Integrate Tailwind CSS (npx astro add tailwind) for utility-first styling
  • Add search with Pagefind — a static search library
  • Implement i18n with Astro's built-in internationalization routing
  • Add a CMS like Decap CMS or Tina CMS for visual editing

The complete source code for this tutorial is available on GitHub. Happy blogging! 🚀