Skip to content

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:

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, and pubDate
  • updatedDate, tags, and draft are optional

Astro will now validate each Markdown file in src/content/blog/ against this schema.

For more on schema validation:


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:

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:

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:

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/lucide and @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:

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:

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:

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.jsonc file
  • 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.