Building a Tech Blog with Astro: Content Collections, MDX, Search and Deployment

Key takeaways

Starting from an empty Astro project, the post adds each piece a real blog needs: a post schema, MDX, taxonomy, search, feeds, sitemaps and language folders. It also explains when to stay fully static and when hybrid or full SSR is worth it before deploying to Cloudflare Pages.

Introduction

Astro is a static site generator optimized for content-focused sites (blogs, documentation, portfolios). It ships zero JavaScript by default, leaving only HTML at build time, and lets you add React, Vue, or Svelte components as islands only where they are needed. This article walks through building a tech blog with Astro: Content Collections, MDX, tags/search/series, RSS/Sitemap, OG images, i18n, choosing between SSG and SSR, and deploying to Cloudflare Pages. For the Cloudflare Pages setup itself, see the Cloudflare Pages deployment guide.

Starting Astro Project

1-1. Project Creation

npm create astro@latest my-blog
cd my-blog
npm install
npm run dev

Template Selection: Selecting Blog template automatically generates basic structure.

1-2. Project Structure

my-blog/
├── src/
│   ├── content/
│   │   ├── blog/
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts      # Content Collections schema
│   ├── pages/
│   │   ├── index.astro
│   │   ├── blog/
│   │   │   ├── [slug].astro
│   │   │   └── tag/[tag].astro
│   │   └── rss.xml.ts
│   ├── components/
│   └── layouts/
├── public/
├── astro.config.mjs
└── package.json

Managing Blog Posts with Content Collections

Content Collections is Astro’s core feature for managing markdown files in a type-safe manner.

2-1. Schema Definition

// src/content/config.ts
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()).default([]),
    draft: z.boolean().default(false),
    author: z.string().default('pkglog'),
    readingMinutes: z.number().optional(),
    relatedPosts: z.array(z.string()).default([]),
  }),
});
export const collections = { blog };

2-2. Writing Markdown

A post is a Markdown file whose frontmatter must match the schema above:

---
title: 'Building a Blog with Astro'
description: 'A getting-started guide for building a blog with Astro.'
pubDate: 2026-04-01
tags: ['Astro', 'Blog', 'JAMstack']
draft: false
---
## Introduction

Astro is optimized for content-focused sites.

2-3. Getting Post List

---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog', ({ data }) => {
  // Exclude drafts, filter by date
  return !data.draft && data.pubDate <= new Date();
});
// Sort by newest
const posts = allPosts.sort((a, b) => 
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<ul>
  {posts.map(post => (
    <li>
      <a href={`/blog/${post.slug}/`}>{post.data.title}</a>
    </li>
  ))}
</ul>

2-4. Individual Post Page

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
  <h1>{post.data.title}</h1>
  <time>{post.data.pubDate.toLocaleDateString('en-US')}</time>
  <Content />
</article>

Type Safety: post.data.title is auto-completed, and build fails on schema violations.

The schema is the part of this setup that pays off most as the blog grows. A missing description, a date written as 2026-13-01, or a tag list written as a string instead of an array fails the build with a message naming the file and the field (blog → my-post.md frontmatter does not match collection schema. description: Required), instead of silently rendering a broken page. z.coerce.date() accepts both 2026-04-01 and '2026-04-01', which matters because YAML turns an unquoted date into a date object and a quoted one into a string.

The code above uses the Astro 2–4 API (src/content/config.ts, type: 'content', post.slug, post.render()), which Astro 5 still accepts as legacy collections. New Astro 5 projects define collections in src/content.config.ts with a loader, loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), identify entries by post.id instead of post.slug, and render with const { Content } = await render(post) imported from astro:content. The concepts are the same; mixing the two styles is what produces errors like post.render is not a function.

Two filters are easy to forget. The listing page excludes drafts, but getStaticPaths in [slug].astro calls getCollection('blog') without a filter, so every draft still gets a public URL, and the tag pages and RSS feed below have the same gap. Put the draft filter in one helper and use it everywhere. And data.pubDate <= new Date() is evaluated at build time on a static site: a post scheduled for tomorrow appears only after the next build that runs tomorrow, so scheduled publishing needs a scheduled rebuild (for example a daily CI job).


Adding Components with MDX

MDX allows writing JSX inside markdown.

3-1. MDX Installation

npm install @astrojs/mdx
// astro.config.mjs
import mdx from '@astrojs/mdx';
export default defineConfig({
  integrations: [mdx()],
});

3-2. Writing MDX Files

An .mdx file can import components right after its frontmatter:

---
title: 'Interactive Example'
pubDate: 2026-04-01
---
import Counter from '../../components/Counter.jsx';

## Counter Example

<Counter client:load />
You can mix regular markdown and components.

3-3. Component Example

// src/components/Counter.jsx
import { useState } from 'react';
export default function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

Client Directives:

  • client:load: Immediately on page load
  • client:idle: When browser is idle
  • client:visible: When entering viewport

Without a client:* directive, Astro renders the React component to static HTML at build time and ships no JavaScript for it: the counter would show “Count: 0” and the button would do nothing. That behaviour is the point of the islands model, but it is also the most common “my component doesn’t work” report from people new to Astro. Pick the laziest directive that works: client:visible for anything below the fold, client:idle for widgets that can wait, and client:load only for what must be interactive immediately. Each hydrated island brings its framework runtime with it, so one React counter adds React to that page’s JavaScript; for a small toggle, a plain <script> in an .astro component is much lighter. Using React components also requires the @astrojs/react integration (npx astro add react), not just the react package.


Tags, Categories & Series

4-1. Tag Pages

---
// src/pages/blog/tag/[tag].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  const tags = [...new Set(posts.flatMap(p => p.data.tags))];
  
  return tags.map(tag => ({
    params: { tag },
    props: {
      posts: posts.filter(p => p.data.tags.includes(tag))
    },
  }));
}
const { tag } = Astro.params;
const { posts } = Astro.props;
---
<h1>Tag: {tag}</h1>
<ul>
  {posts.map(post => (
    <li><a href={`/blog/${post.slug}/`}>{post.data.title}</a></li>
  ))}
</ul>

Tags come from free-form frontmatter, so they drift: React, react and React.js become three separate tag pages with one post each. Normalizing (lowercasing, trimming, mapping aliases) in the schema with a Zod .transform() keeps URLs stable. Tags with spaces, slashes or non-ASCII characters also need care: Astro URL-encodes the param, and a tag containing / produces a nested path that breaks the route. Generating a slug for the URL and keeping the original string for display avoids both problems. Finally, many thin tag pages with one or two posts each add little for readers or search engines; a site with thousands of tags often marks tag pages noindex and keeps them for navigation only.

4-2. Series Management

// src/content/config.ts
const blog = defineCollection({
  schema: z.object({
    // ...
    seriesId: z.string().optional(),
    seriesOrder: z.number().optional(),
  }),
});
// Get series posts
const seriesPosts = allPosts
  .filter(p => p.data.seriesId === 'algorithm')
  .sort((a, b) => (a.data.seriesOrder || 0) - (b.data.seriesOrder || 0));

5-1. Client-side Search (Fuse.js)

npm install fuse.js
---
// src/pages/search.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const searchData = posts.map(p => ({
  slug: p.slug,
  title: p.data.title,
  description: p.data.description,
  tags: p.data.tags,
}));
---
<input id="search" type="text" placeholder="Search..." data-index={JSON.stringify(searchData)} />
<div id="results"></div>
<script>
  // A normal (non-inline) script: Astro bundles it, so npm imports work
  import Fuse from 'fuse.js';
  
  const input = document.getElementById('search') as HTMLInputElement;
  const fuse = new Fuse(JSON.parse(input.dataset.index ?? '[]'), {
    keys: ['title', 'description', 'tags'],
    threshold: 0.3,
  });
  
  input.addEventListener('input', () => {
    const results = fuse.search(input.value);
    // Render results
  });
</script>

An earlier version of this snippet passed the data with <script define:vars={{ searchData }}> and imported Fuse inside that script. That combination cannot work: define:vars turns the script into an inline script that Astro does not process or bundle, so the bare import Fuse from 'fuse.js' reaches the browser untouched and fails (Uncaught SyntaxError: Cannot use import statement outside a module, or a failed module specifier resolution). Passing data through a data- attribute keeps the script bundled. For more than a few hundred posts, embedding the index in the page gets heavy, and serving it as a separate JSON file from an endpoint, fetched when the search box is focused, keeps page weight down.

5-2. Server Search (Pagefind)

npm install -D pagefind
// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

Automatically generates search index after build.


RSS, Sitemap & OG Images

6-1. RSS Feed

// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
  const posts = await getCollection('blog');
  
  return rss({
    title: 'My Blog',
    description: 'Tech Blog',
    site: context.site,
    items: posts.map(post => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      link: `/blog/${post.slug}/`,
    })),
  });
}

6-2. Sitemap

// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
  site: 'https://example.com',
  integrations: [sitemap()],
});

Automatically generates dist/sitemap-index.xml at build time.

6-3. OG Images (Satori)

npm install satori sharp
// src/pages/og/[slug].png.ts
import { getCollection } from 'astro:content';
import satori from 'satori';
import sharp from 'sharp';
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}
export async function GET({ props }) {
  const { post } = props;
  
  const svg = await satori(
    <div style={{ 
      width: '1200px', 
      height: '630px',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      padding: '80px',
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      color: 'white',
    }}>
      <h1 style={{ fontSize: '64px', margin: 0 }}>{post.data.title}</h1>
      <p style={{ fontSize: '32px', marginTop: '20px' }}>{post.data.description}</p>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [/* Load fonts */],
    }
  );
  
  const png = await sharp(Buffer.from(svg)).png().toBuffer();
  
  return new Response(png, {
    headers: { 'Content-Type': 'image/png' },
  });
}

Meta Tags:

<meta property="og:image" content={`https://example.com/og/${slug}.png`} />

This endpoint is a sketch with three things to fix before it runs. JSX in a .ts file does not compile; either use Satori’s plain-object form ({ type: 'div', props: { style, children } }) or a helper such as satori-html. The empty fonts array makes Satori throw (No fonts are loaded. At least one font is required to calculate the layout.); load a .ttf/.otf file as an ArrayBuffer, and for Korean or other CJK titles use a font that actually contains those glyphs, or the text renders as empty boxes. And Satori supports only a subset of CSS with flexbox layout, so every element with more than one child needs display: 'flex'. Rendering an image per post also dominates build time on large blogs, which is why the caching script in section 10 generates images once and stores them.


Internationalization (i18n)

7-1. Configuration

// astro.config.mjs
export default defineConfig({
  i18n: {
    defaultLocale: 'ko',
    locales: ['ko', 'en'],
    routing: {
      prefixDefaultLocale: false, // /blog/ without /ko/
    },
  },
});

7-2. Language-specific Folders

src/content/blog/
├── my-post.md          # Korean
└── en/
    └── my-post.md      # English

7-3. Language Switching

// src/utils/i18n.ts
export function getAlternateSlug(slug: string, locale: string) {
  if (locale === 'en') return `en/${slug}`;
  return slug.replace(/^en\//, '');
}
<link rel="alternate" hreflang="en" href={new URL(`/blog/${getAlternateSlug(slug, 'en')}/`, Astro.site)} />
<link rel="alternate" hreflang="ko" href={new URL(`/blog/${slug}/`, Astro.site)} />

Two rules make hreflang work in practice. The URLs must be absolute (Google ignores relative hreflang URLs), which is why the example builds them from Astro.site. And the annotations must be reciprocal: the English page has to point back to the Korean one, and each page should list itself. Emit the tags only when the translated file actually exists; linking every Korean post to an en/ URL that returns 404 is a common mistake when only some posts are translated. Keeping the same file name in both folders, as in 7-2, makes that existence check a simple lookup.


SSR vs SSG Selection

Astro defaults to SSG (Static Site Generation) but can switch specific pages to SSR.

8-1. Full SSG (Default)

// astro.config.mjs
export default defineConfig({
  output: 'static', // Default
});

All pages generated as HTML at build time.

8-2. Hybrid (Partial SSR)

export default defineConfig({
  output: 'static',        // Astro 5: static + adapter = mostly static, some on-demand pages
  adapter: cloudflare(),   // or node(), vercel()
});

Astro 4 called this mode output: 'hybrid'. Astro 5 removed that option and merged it into static: with an adapter installed, any page or endpoint that exports prerender = false is rendered on demand and everything else is still built ahead of time. Configs copied from older tutorials fail with an error about the invalid output value after an upgrade.

// src/pages/api/views.ts
export const prerender = false; // Only this page uses SSR
export async function GET() {
  const views = await getViewCount();
  return new Response(JSON.stringify({ views }));
}

8-3. Full SSR

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
});

All pages rendered on each request.

Selection Criteria:

  • Blog posts: SSG (HTML at build time)
  • View counts/comments: SSR API or client fetch
  • Search: Client-side search or SSR endpoint

For a blog, full SSR rarely pays for itself. Static pages are served from a CDN cache with no server code at all, cannot fail because a function is cold or over its CPU limit, and cost nothing per request. The trade-off shows up only when content must change without a rebuild, such as view counts, comments or personalised pages, and those are usually small enough to handle with one on-demand endpoint or a third-party widget while the article pages stay static. I would move to full SSR only if builds became too slow to run on every change, and at that point it is worth checking first whether image generation or a plugin is the real cause.


Deployment (Cloudflare Pages)

9-1. GitHub Integration

  1. Cloudflare Dashboard → Pages → Create a project
  2. Connect GitHub repository
  3. Build settings:
    • Framework preset: Astro
    • Build command: npm run build
    • Build output directory: dist

9-2. Wrangler CLI

npm install -D wrangler
npm run build
wrangler pages deploy dist --project-name=my-blog

9-3. GitHub Actions

name: Deploy to Cloudflare Pages
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
        env:
          NODE_OPTIONS: '--max-old-space-size=4096'
      
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist --project-name=my-blog

For detailed deployment settings, see the Cloudflare Pages deployment guide.

The NODE_OPTIONS: '--max-old-space-size=4096' line is there for a reason. Astro keeps all collection entries and rendered pages’ metadata in memory during a build, and a blog with a few thousand posts plus remark/rehype plugins can exceed Node’s default heap, failing with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Raising the heap limit is the quick fix; finding which plugin or page generator holds on to large objects is the lasting one. Also set site in astro.config.mjs to the production URL, because the sitemap, RSS links and canonical URLs are all built from it, and a missing site produces a sitemap that silently lacks absolute URLs or a build warning, depending on the integration.


Build, Content and Integration Details

10-1. Build Performance

Large Pages (1,000+):

// astro.config.mjs
export default defineConfig({
  build: {
    concurrency: 16, // Parallel rendering
  },
});

OG Image Caching:

// scripts/generate-og-images.mjs
import { existsSync } from 'fs';
for (const post of posts) {
  const ogPath = `public/og/${post.slug}.png`;
  if (existsSync(ogPath)) {
    console.log(`Using cache: ${post.slug}`);
    continue;
  }
  // Generation logic
}

10-2. Reading Time Calculation

// src/utils/reading-time.ts
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 200; // Lower for Korean
  const words = content.split(/\s+/).length;
  return Math.ceil(words / wordsPerMinute);
}

Counting whitespace-separated words works for English but badly for Korean, Japanese or Chinese, where a “word” split by spaces is a much longer unit (or, for Japanese and Chinese, the whole paragraph). A character-based estimate (roughly 500 characters per minute for Korean) is more realistic. post.body also includes code blocks and raw Markdown syntax, which inflates the estimate for code-heavy posts; stripping fenced code first, or counting it at a lower rate, gives numbers closer to how long reading actually takes.

// src/pages/blog/[slug].astro
const readingTime = calculateReadingTime(post.body);
// src/utils/related-posts.ts
export function getRelatedPosts(currentPost, allPosts) {
  return allPosts
    .filter(p => p.slug !== currentPost.slug)
    .map(p => ({
      post: p,
      score: countCommonTags(currentPost.data.tags, p.data.tags),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
    .map(item => item.post);
}
function countCommonTags(tags1, tags2) {
  return tags1.filter(t => tags2.includes(t)).length;
}

10-4. Code Highlighting

Astro uses Shiki by default.

// astro.config.mjs
export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: 'github-dark',
      langs: ['javascript', 'typescript', 'python', 'cpp'],
    },
  },
});

10-5. Comments (Giscus)

<!-- src/components/Comments.astro -->
<script
  src="https://giscus.app/client.js"
  data-repo="username/repo"
  data-repo-id="..."
  data-category="Comments"
  data-category-id="..."
  data-mapping="pathname"
  data-reactions-enabled="1"
  data-theme="light"
  async
></script>

Advanced Features

11-1. View Transitions (Page Transition Animations)

---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';   // named ViewTransitions before Astro 5
---
<html>
  <head>
    <ClientRouter />
  </head>
  <body>
    <slot />
  </body>
</html>

Smooth transition effects when navigating pages. The component turns the site into a client-side router: navigation fetches the next page and swaps the DOM instead of doing a full page load. The side effect is that scripts do not re-run on navigation the way they do on a normal page load, so code that attaches event listeners, initializes a comment widget or pushes an analytics page view on load runs only once. Such code has to listen for the astro:page-load event instead, and third-party scripts that assume a full reload (ad and analytics tags in particular) need to be checked individually.

11-2. Middleware

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
  const start = Date.now();
  const response = await next();
  
  console.log(`${context.url.pathname} - ${Date.now() - start}ms`);
  
  return response;
});

11-3. Environment Variables

// .env
PUBLIC_SITE_URL=https://example.com
PRIVATE_API_KEY=secret123
// PUBLIC_ prefix allows client-side access
const siteUrl = import.meta.env.PUBLIC_SITE_URL;
// Any variable without the PUBLIC_ prefix is server-only (the PRIVATE_ name is just a convention)
const apiKey = import.meta.env.PRIVATE_API_KEY;

On a static site, “server-only” means build-time only: the value is read while building and whatever the page renders with it ends up in the HTML. Rendering an API key into a page, or reading it inside a component that hydrates on the client, exposes it. PUBLIC_ variables are also inlined at build time, so changing one in the hosting dashboard has no effect until the next build.


Summary

Key Summary

Astro Blog Advantages:

  • Fast Speed: Zero JS, static HTML
  • Type Safety: Content Collections
  • Flexibility: MDX, React/Vue islands
  • SEO: Automated RSS, Sitemap, OG images Recommended Stack:
  • Framework: Astro 5+
  • Styling: Tailwind CSS
  • Search: Fuse.js or Pagefind
  • Comments: Giscus (GitHub Discussions)
  • Deployment: Cloudflare Pages
  • CI/CD: GitHub Actions

Further Reading


Frequently Asked Questions (FAQ)

A. Fuse.js ships a JSON index to the browser and fuzzy-matches it in memory, which is simple and fine for a small blog, but the download grows with every post. Pagefind runs after astro build over the generated HTML and creates a chunked index that the browser fetches in pieces, so it scales to thousands of pages. Remember to add Pagefind as a post-build step, or the index will be missing in production.