Beginner Astro Blog with Tailwind & Cloudflare
Part 3: Content, Layouts & Components
Use Astro content collections, layouts, astro-icon, and a table of contents to turn your styled shell into a real blog.
In this part, we move from “nice styling” to “actual blog.” You’ll learn how to manage posts with Astro’s content collections, share layouts between pages, and use astro-icon and a table of contents component to make long form content easier to read.
By now you should have:
- An Astro v5 project running locally
- Tailwind CSS v4 integrated
- A basic design system and layout
Now it’s time to give your blog real content.
Step 1 - Understand Astro Content Collections
Astro’s content collections give you:
- Typed frontmatter (titles, descriptions, dates, tags)
- A single place to define what every post must include
- Easy querying of posts in your pages and components
For more details on content collections:
- Astro Content Collections Guide: https://docs.astro.build/en/guides/content-collections/
We’ll create a blog collection in src/content.
Create src/content/config.ts (if it doesn’t exist) with:
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).optional(),
draft: z.boolean().optional(),
}),
})
export const collections = { blog }
This says:
- Every blog post must have a
title,description, andpubDate updatedDate,tags, anddraftare optional
Astro will now validate each Markdown file in src/content/blog/ against this schema.
For more on schema validation:
- Zod Schema Documentation: https://zod.dev/
Step 2 - Create Your First Blog Post
Inside src/content/blog/, create a file like hello-world.md:
---
title: 'Hello, Astro Blog'
description: 'My first post using Astro content collections.'
pubDate: 2025-12-01
tags: ['intro', 'astro', 'blog']
draft: false
---
Welcome to my new Astro blog!
This is my first post using Astro's **content collections**.
In future posts, I'll share what I'm learning about Astro, Tailwind, and Cloudflare.
Save the file. If you run npm run build later, Astro will check that this post matches the schema.
Step 3 - Build a Reusable Post Layout
Next, we’ll create a layout that all posts share. It will:
- Wrap content in your base layout
- Show the title, dates, and tags
- Leave room for a table of contents on the side
For more on Astro layouts:
- Astro Layouts Guide: https://docs.astro.build/en/basics/layouts/
Create src/layouts/PostLayout.astro:
---
import BaseLayout from './BaseLayout.astro'
interface Props {
title: string
description: string
pubDate: Date
updatedDate?: Date
tags?: string[]
}
const { title, description, pubDate, updatedDate, tags } = Astro.props
---
<BaseLayout title={title}>
<article class="mx-auto max-w-3xl py-8">
<header class="mb-6">
<p class="text-xs font-medium tracking-wide text-text-secondary uppercase">Blog</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-text-primary">
{title}
</h1>
<p class="mt-2 text-sm text-text-secondary">
{description}
</p>
<div class="mt-4 flex items-center gap-3 text-xs text-text-tertiary">
<time datetime={pubDate.toISOString()}>
{pubDate.toLocaleDateString()}
</time>
{
updatedDate && (
<>
<span aria-hidden="true">•</span>
<span>Updated {updatedDate.toLocaleDateString()}</span>
</>
)
}
{
tags && tags.length > 0 && (
<>
<span aria-hidden="true">•</span>
<ul class="flex flex-wrap gap-2">
{tags.map((tag: string) => (
<li class="rounded-full border border-border-subtle bg-bg-tertiary px-2 py-0.5">
<span class="text-[0.7rem] tracking-wide uppercase">{tag}</span>
</li>
))}
</ul>
</>
)
}
</div>
</header>
<div class="prose prose-sm max-w-none text-text-primary">
<slot />
</div>
</article>
</BaseLayout>
This layout doesn’t yet include the table of contents sidebar, but it gives you a solid structure for the content itself.
Step 4 - Connect Posts to a Dynamic Route
We’ll now create a dynamic page that looks up a post by slug and renders it with PostLayout.
For more on dynamic routing:
- Astro Dynamic Routes Guide: https://docs.astro.build/en/guides/routing/#dynamic-routes
Create src/pages/blog/[...slug].astro:
---
import { getCollection } from 'astro:content'
import PostLayout from '../../layouts/PostLayout.astro'
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft)
return posts.map((post) => ({
params: { slug: post.slug.split('/') },
props: { post },
}))
}
interface Props {
post: import('astro:content').CollectionEntry<'blog'>
}
const { post } = Astro.props as Props
const { data, body } = post
---
<PostLayout
title={data.title}
description={data.description}
pubDate={data.pubDate}
updatedDate={data.updatedDate}
tags={data.tags}
>
<Fragment set:html={body} />
</PostLayout>
This does a few important things:
- Uses
getCollection("blog")to gather posts - Filters out drafts
- Passes the post content and frontmatter into
PostLayout
Now when you visit /blog/hello-world/, Astro will render the hello-world.md post with your layout.
Step 5 - Create a Blog Index Page
Let’s add an index page that lists all posts.
For more on working with content collections:
- CollectionEntry Type Reference: https://docs.astro.build/en/reference/api-reference/#collectionentry-type
Create src/pages/blog/index.astro:
---
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
const posts = await getCollection('blog', ({ data }) => !data.draft)
---
<BaseLayout title="Blog">
<section class="mx-auto max-w-3xl py-8">
<header class="mb-8">
<p class="text-xs font-medium tracking-wide text-text-secondary uppercase">Blog</p>
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-text-primary">Latest posts</h1>
<p class="mt-2 text-sm text-text-secondary">
Long-form notes about Astro, Tailwind, Cloudflare, and what I'm learning.
</p>
</header>
<ul class="space-y-6">
{
posts
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
.map((post) => (
<li>
<a href={`/blog/${post.slug}/`} class="group block">
<p class="text-xs text-text-tertiary">{post.data.pubDate.toLocaleDateString()}</p>
<h2 class="mt-1 text-base font-semibold text-text-primary group-hover:text-accent">
{post.data.title}
</h2>
<p class="mt-1 text-sm text-text-secondary">{post.data.description}</p>
</a>
</li>
))
}
</ul>
</section>
</BaseLayout>
This gives you a simple blog index that’s easy to extend later with tags, pagination, or search.
Step 6 - Add Icons with astro-icon and Iconify
To add icons without pulling in a full icon library by hand, we’ll use:
astro-icon- an Astro component wrapper- Iconify icon sets, like
@iconify-json/lucideand@iconify-json/simple-icons
Install them:
npm install astro-icon
npm install -D @iconify-json/lucide @iconify-json/simple-icons
For more on using astro-icon:
- Astro Icon Documentation: https://www.astroicon.dev/
Update astro.config.mjs to include the integration:
import icon from 'astro-icon'
export default defineConfig({
// ...rest of config
integrations: [
icon({
include: {
lucide: ['pen-tool', 'coffee', 'search', 'sun', 'moon'],
'simple-icons': ['github', 'twitter'],
},
}),
],
})
To browse available icons:
- Iconify Icon Set Browser: https://iconify.design/
Now you can use icons anywhere:
---
import Icon from 'astro-icon'
---
<a href="/blog" class="inline-flex items-center gap-1 text-sm">
<Icon name="lucide:pen-tool" class="h-4 w-4" />
<span>Blog</span>
</a>
Step 7 - Plan a Table of Contents for Long Posts
A table of contents (TOC) makes long posts much more approachable, especially for beginners. At a high level, a TOC component:
- Scans the page for headings (
<h2>,<h3>, etc.) - Builds a list of links anchored to those headings
- Highlights the current section as you scroll
For context on interactive components:
- Astro View Transitions Guide: https://docs.astro.build/en/guides/view-transitions/
The implementation can be a bit advanced, so don’t worry if it feels like magic at first. You can:
- Start with a simple list of section links at the top of each post
- Later, upgrade it to a floating sidebar TOC once you’re comfortable
As long as your content uses consistent heading levels, you can always add a more advanced TOC later without changing your posts.
Recap and What’s Next
In this part you:
- Defined a blog content collection with a typed schema
- Created your first post in
src/content/blog - Built a reusable PostLayout for all posts
- Added a blog index page that lists posts
- Integrated astro-icon and Iconify icons
Your Astro project is now a real content site, not just a styled landing page.
In Part 4, we’ll take the final step:
- Configure the Cloudflare adapter in Astro
- Set up Wrangler with a
wrangler.jsoncfile - Use both the Cloudflare dashboard and the CLI to deploy your site to Workers
After that, your beginner‑friendly Astro blog will be live on the edge.