Content collections, dynamic routes, and a working blog with zero dependencies beyond Astro itself. Here’s exactly how.
I built this blog in about 30 minutes. Not because I’m particularly fast, but because Astro’s content system is genuinely that simple. No plugins to install, no config files to wrestle with, no build tool drama. You define a collection, write some markdown, create two pages, and you have a working blog.
This is the exact approach that powers the twelvetake.com blog right now. Every code example in this tutorial is pulled from the real implementation. Nothing theoretical, nothing “should work in theory” — this is what’s actually running in production.
By the end of this walkthrough, you’ll have:
- A type-safe content collection with Zod schema validation
- A blog listing page that automatically picks up new posts
- Individual post pages with full markdown rendering
- GFM support (tables, footnotes, strikethrough, task lists) with zero config
- No external dependencies beyond Astro itself
Let’s build it.
Prerequisites
You need an existing Astro 5 project. If you don’t have one yet, create it:
npm create astro@latest
Follow the prompts. Pick whichever template you want — the blog system we’re building works with any of them. Just make sure you’re on Astro 5 or later, since we’re using the Content Layer API which replaced the old content collections system.
That’s it for prerequisites. No packages to add, no integrations to configure.
Step 1: Define Your Content Collection
The content collection is where Astro’s blog system starts. It tells Astro: “Here’s a folder full of markdown files. Here’s what their frontmatter looks like. Keep them organized and type-safe for me.”
Create the file src/content.config.ts:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.date(),
author: z.string(),
tags: z.array(z.string()),
image: z.string().optional(),
}),
});
export const collections = { blog };
Let’s break this down piece by piece because every line is doing something useful.
defineCollection creates a named collection. You can have as many as you want — blog, projects, docs, whatever. Each one is independent with its own schema and loader.
The glob loader is how Astro 5’s Content Layer API finds your files. You give it a glob pattern (**/*.md matches all markdown files, including in subdirectories) and a base directory. This replaced the older convention-based system where Astro would auto-detect collections based on folder names inside src/content/. The new approach is more explicit and more flexible.
The Zod schema is where this gets good. Every frontmatter field is validated at build time. If you forget to add a title to a post, Astro won’t build — it’ll tell you exactly which file has the problem and which field is missing. If you accidentally put a string where a date should be, same thing. This catches errors before they ever reach production.
The image field is marked .optional() because not every post needs a hero image. Everything else is required.
Why not just read markdown files manually? You could. You could use import.meta.glob or Node’s fs module and parse frontmatter yourself. But you’d lose type safety, build-time validation, automatic slug generation, and the clean API we’re about to use. The collection system gives you all of that for about 15 lines of config.
Now create the directory for your posts:
src/
content/
blog/
This matches the base path in the glob loader. Any .md file you drop in here (or in subdirectories) will automatically become part of the collection.
Step 2: Write Your First Post
Create src/content/blog/hello-world.md:
---
title: "Hello World: My First Post"
description: "Testing out the new blog system. Everything works."
date: 2026-01-28T12:00:00
author: "Your Name"
tags: ["meta", "first post"]
---
This is your first blog post. Write whatever you want here.
## Markdown works
All standard markdown features work out of the box:
- **Bold text** and *italic text*
- [Links](https://example.com)
- Code blocks with syntax highlighting
- Images, blockquotes, lists -- everything
You can also use GFM features like tables:
| Feature | Works? |
|---------|--------|
| Tables | Yes |
| Footnotes | Yes |
| Strikethrough | ~~Yes~~ |
| Task lists | Yes |
The frontmatter between the --- fences must match your Zod schema. Here’s what each field does:
title— displayed as the post heading and in the listing pagedescription— used for the post preview card and meta tagsdate— determines sort order and display dateauthor— displayed on the post pagetags— array of strings for categorization
The Date Gotcha
This one bit me and it’ll bite you too if you’re not careful. Look at these two date formats:
# This will cause problems:
date: 2026-01-28
# This is what you want:
date: 2026-01-28T12:00:00
When you write just 2026-01-28, Zod (and JavaScript’s Date constructor) interprets that as midnight UTC. If you’re in any timezone west of UTC — which covers all of the Americas — that UTC midnight rolls back to the previous day in your local time. Your January 28 post shows up as January 27.
The fix is dead simple: add a time component. T12:00:00 puts the timestamp at noon UTC, which displays as the correct date in every timezone from UTC-12 to UTC+12. I use noon for every post. You’ll never think about this again once you know the pattern, but it’s genuinely confusing the first time you see your post dated a day early and have no idea why.
Step 3: The Blog Listing Page
This is the page at /blog that shows all your posts. Create src/pages/blog/index.astro:
---
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog')).sort(
(a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
---
<Layout title="Blog" description="Latest posts">
<main>
<h1>Blog</h1>
{posts.length === 0 ? (
<p>No posts yet. Check back soon.</p>
) : (
<ul class="post-list">
{posts.map(post => (
<li>
<a href={`/blog/${post.id}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<div class="post-meta">
<span>{post.data.author}</span>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
<div class="tags">
{post.data.tags.map(tag => (
<span class="tag">{tag}</span>
))}
</div>
</a>
</li>
))}
</ul>
)}
</main>
</Layout>
A few things worth noting here.
getCollection('blog') returns every entry in your blog collection. Each entry has a data property (your typed frontmatter) and an id property (the filename without the extension, which we’ll use as the URL slug). This is fully typed — if you try to access post.data.title, your editor knows it’s a string. If you try to access post.data.somethingThatDoesntExist, TypeScript will catch it.
Sorting is done inline. We sort by date descending so the newest posts appear first. Since date is already validated as a Date object by Zod, we can call getTime() on it directly. No parsing, no new Date(someString) hoping it works.
The post.id is derived from the file path relative to the collection’s base directory. A file at src/content/blog/hello-world.md gets an id of hello-world. A file at src/content/blog/2026/my-post.md gets an id of 2026/my-post. This becomes the URL slug, so keep your filenames clean and URL-friendly.
The empty state (posts.length === 0) is a small thing but worth including. Without it, an empty blog shows a blank page, which looks like something’s broken.
Replace Layout with whatever layout component your project uses. The import path will depend on your project structure.
Step 4: Individual Post Pages
This is the dynamic route that renders each blog post. Create src/pages/blog/[slug].astro:
---
import Layout from '../../layouts/Layout.astro';
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<Layout title={post.data.title} description={post.data.description}>
<article>
<header>
<a href="/blog">Back to blog</a>
<h1>{post.data.title}</h1>
<div class="post-meta">
<span>{post.data.author}</span>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
<div class="tags">
{post.data.tags.map(tag => (
<span class="tag">{tag}</span>
))}
</div>
</header>
<div class="blog-content">
<Content />
</div>
</article>
</Layout>
This file does two things that are worth understanding.
getStaticPaths() is how Astro generates static pages for dynamic routes. At build time, it calls this function to get a list of every possible path. For each post in the collection, we return an object with params (the slug for the URL) and props (the full post object, so we don’t have to fetch it again). Astro then generates a static HTML page for each one.
render(post) is the Content Layer API’s way of turning a collection entry into renderable content. It returns a Content component that you drop into your template. The <Content /> component handles everything — headings, links, code blocks, images, tables, the works. Your markdown becomes HTML with zero effort on your part.
This is a pattern I really appreciate about Astro: the data fetching happens in the frontmatter fence (the --- block), and the template just renders it. There’s no useEffect, no loading states, no hydration. At build time, it’s all resolved to static HTML.
Step 5: Styling the Rendered Content
Here’s the part that trips people up. You’ve got <Content /> rendering your markdown into HTML, but it comes out unstyled. Headings look like body text, links aren’t colored, code blocks are plain, and tables are invisible. If you’re using a CSS reset (and you should be), everything is flattened.
You need to target the wrapper element and style the generated HTML inside it. We used class="blog-content" as the wrapper. Here’s a baseline set of styles:
.blog-content h2 {
font-size: 1.75rem;
font-weight: 700;
margin-top: 2.5rem;
margin-bottom: 1rem;
line-height: 1.2;
}
.blog-content h3 {
font-size: 1.375rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.75rem;
}
.blog-content p {
margin-bottom: 1.25rem;
line-height: 1.75;
}
.blog-content a {
color: #2563eb;
text-decoration: underline;
}
.blog-content a:hover {
color: #1d4ed8;
}
.blog-content ul,
.blog-content ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.blog-content li {
margin-bottom: 0.5rem;
line-height: 1.75;
}
.blog-content blockquote {
border-left: 4px solid #d1d5db;
padding-left: 1rem;
margin: 1.5rem 0;
color: #6b7280;
font-style: italic;
}
.blog-content pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1.25rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1.5rem;
font-size: 0.875rem;
line-height: 1.7;
}
.blog-content code {
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.875em;
}
.blog-content :not(pre) > code {
background: #f3f4f6;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
color: #dc2626;
}
.blog-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
}
.blog-content th,
.blog-content td {
border: 1px solid #d1d5db;
padding: 0.625rem 1rem;
text-align: left;
}
.blog-content th {
background: #f9fafb;
font-weight: 600;
}
.blog-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1.5rem 0;
}
.blog-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 2.5rem 0;
}
You can put this in a global stylesheet, in a <style> tag on the post page, or in whatever CSS approach your project uses. The point is that the rendered markdown content needs explicit styles because Astro (correctly) doesn’t impose any opinions about typography.
If you’re using Tailwind CSS, the @tailwindcss/typography plugin gives you a prose class that handles all of this automatically. But the manual approach above works everywhere, with any project setup.
GFM Support: It Just Works
One of the nicest surprises in Astro is that GitHub Flavored Markdown support is built in. Astro uses @astrojs/markdown-remark under the hood, which bundles remark-gfm. You don’t need to install anything or configure anything. It’s just there.
That means all of the following work out of the box in your posts:
Tables:
| Column A | Column B |
|----------|----------|
| Data 1 | Data 2 |
Footnotes:
This claim needs a source[^1].
[^1]: Here's the source.
Strikethrough:
~~This text is struck through.~~
Task lists:
- [x] Completed task
- [ ] Incomplete task
No remark-gfm in your astro.config.mjs, no plugins array to manage, no version conflicts. If you’ve used other static site generators where getting GFM tables to render correctly involves three packages and a custom config, this is a breath of fresh air.
What You Get
At this point, here’s what you have:
src/content.config.ts— collection definition with type-safe schemasrc/content/blog/— directory for your markdown postssrc/pages/blog/index.astro— listing page at/blogsrc/pages/blog/[slug].astro— individual post pages at/blog/post-name
The entire system is four files. To publish a new post, you create a markdown file in the blog directory with the correct frontmatter, and Astro handles the rest. The listing page picks it up automatically. The routing creates a page for it automatically. The schema validates the frontmatter at build time. You never touch the template code again unless you want to change the design.
That’s genuinely it. No database. No CMS. No API calls. Your content lives in your repo as markdown files, version-controlled alongside your code.
Going Further
The basic setup covers most blogs, but there are a few things you might want to add as your blog grows:
RSS feed. Astro has an official @astrojs/rss package. It takes about 10 minutes to add and your readers will appreciate it. Create a src/pages/rss.xml.ts file that exports a GET function using the rss() helper, feed it your collection data, and you’re done.
Tag pages. Right now tags are displayed but don’t link anywhere. You could create a dynamic route at src/pages/blog/tag/[tag].astro that filters the collection by tag. The getStaticPaths function would generate a route for each unique tag across all posts.
Pagination. If you end up with dozens of posts, you’ll want to paginate the listing page. Astro has a built-in paginate() function that works with getStaticPaths to generate numbered pages like /blog/2, /blog/3, etc.
Search. Client-side search with something like Pagefind or Fuse.js works well for static blogs. Pagefind in particular is designed for static sites and indexes your content at build time.
Reading time. Count the words in your post content and divide by 200-250 (average reading speed). Display it in the post header. It’s a nice touch that helps readers decide whether to commit to a post.
But don’t over-engineer it upfront. The four-file setup handles a surprising amount of blog. Add complexity when you actually need it, not because a tutorial told you that you might.
Wrapping Up
I’ve used a lot of frameworks for content sites. Astro’s Content Layer API is the most pleasant content system I’ve worked with for static blogs. The type safety catches real errors. The glob loader is flexible without being complicated. The rendering pipeline handles markdown, code highlighting, and GFM features without any configuration. And the whole thing builds to static HTML that loads instantly.
If you’re considering Astro for a blog or content site, this is the setup I’d recommend starting with. It’s what we use at TwelveTake Studios, and it’s been rock solid. If you want to read more about why we chose Astro over other frameworks, I wrote about that separately.
Drop your markdown files in the folder, run npm run build, and ship it.