Building a Tech Blog with Astro | Content Collections, MDX, SEO & Deployment

Building a Tech Blog with Astro | Content Collections, MDX, SEO & Deployment

이 글의 핵심

Complete guide to building a tech blog with Astro. Covers Content Collections, MDX, tags/search/series, RSS/Sitemap, OG images, i18n, SSR/SSG selection, and Cloudflare Pages deployment with practical examples.

Introduction

Astro is a static site generator optimized for content-focused sites (blogs, documentation, portfolios). It defaults to Zero JavaScript leaving only HTML at build time, and allows adding React, Vue, or Svelte components as Islands only where needed.

This article covers the entire process of building a tech blog with Astro: Content Collections, MDX, tags/search/series, RSS/Sitemap, OG images, i18n, SSR/SSG selection, and Cloudflare Pages deployment.

For post-deployment search traffic structure, see Growing Tech Blog Traffic, and for Cloudflare Pages setup, refer to Complete Cloudflare Pages Guide.


Reality in Practice

When learning development, everything seems clean and theoretical. But practice is different. You wrestle with legacy code, chase tight deadlines, and face unexpected bugs. The content covered in this article was initially learned as theory, but it was through applying it to actual projects that I realized “Ah, this is why it’s designed this way.”

What stands out in my memory is the trial and error from my first project. I did everything by the book but couldn’t figure out why it wasn’t working, spending days struggling. Eventually, through a senior developer’s code review, I discovered the problem and learned a lot in the process. In this article, I’ll cover not just theory but also the pitfalls you might encounter in practice and how to solve them.

Table of Contents

  1. Starting Astro Project
  2. Managing Blog Posts with Content Collections
  3. Adding Components with MDX
  4. Tags, Categories & Series
  5. Search Functionality
  6. RSS, Sitemap & OG Images
  7. Internationalization (i18n)
  8. SSR vs SSG Selection
  9. Deployment (Cloudflare Pages)
  10. Summary

1. Starting Astro Project

1-1. Project Creation

Here’s a simple bash code example. Try running the code directly to see how it works.

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

Here’s a detailed implementation using text. Please review the code to understand the role of each part.

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

2. 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

Here’s a detailed implementation using TypeScript. Import necessary modules. Please review the code to understand the role of each part.

// 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

Here’s an implementation example using Markdown. Please review the code to understand the role of each part.

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

## Introduction

Astro is optimized for content-focused sites.

2-3. Getting Post List

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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.


3. Adding Components with MDX

MDX allows writing JSX inside markdown.

3-1. MDX Installation

npm install @astrojs/mdx

Here’s an implementation example using JavaScript. Import necessary modules. Try running the code directly to see how it works.

// astro.config.mjs
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
});

3-2. Writing MDX Files

Here’s an implementation example using MDX. Import necessary modules. Please review the code to understand the role of each part.

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

Here’s an implementation example using JSX. Import necessary modules. Please review the code to understand the role of each part.

// 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

4. Tags, Categories & Series

4-1. Tag Pages

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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>

4-2. Series Management

Here’s an implementation example using TypeScript. Try running the code directly to see how it works.

// src/content/config.ts
const blog = defineCollection({
  schema: z.object({
    // ...
    seriesId: z.string().optional(),
    seriesOrder: z.number().optional(),
  }),
});

Here’s a simple TypeScript code example. Process data with loops. Try running the code directly to see how it works.

// 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

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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,
}));
---

<script define:vars={{ searchData }}>
  import Fuse from 'fuse.js';
  
  const fuse = new Fuse(searchData, {
    keys: ['title', 'description', 'tags'],
    threshold: 0.3,
  });
  
  document.getElementById('search').addEventListener('input', (e) => {
    const results = fuse.search(e.target.value);
    // Render results
  });
</script>

<input id="search" type="text" placeholder="Search..." />
<div id="results"></div>

5-2. Server Search (Pagefind)

npm install -D pagefind

Here’s an implementation example using JSON. Try running the code directly to see how it works.

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

Automatically generates search index after build.


6. RSS, Sitemap & OG Images

6-1. RSS Feed

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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

Here’s an implementation example using JavaScript. Import necessary modules, process data with loops. Try running the code directly to see how it works.

// 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

Here’s a detailed implementation using TypeScript. Import necessary modules, perform tasks efficiently with async processing, process data with loops. Please review the code to understand the role of each part.

// 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`} />

7. Internationalization (i18n)

7-1. Configuration

Here’s an implementation example using JavaScript. Please review the code to understand the role of each part.

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

7-2. Language-specific Folders

Here’s a simple text code example. Try running the code directly to see how it works.

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

7-3. Language Switching

Here’s an implementation example using TypeScript. Handle branching with conditionals. Try running the code directly to see how it works.

// 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={`/blog/${getAlternateSlug(slug, 'en')}/`} />
<link rel="alternate" hreflang="ko" href={`/blog/${slug}/`} />

8. SSR vs SSG Selection

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

8-1. Full SSG (Default)

Here’s a simple JavaScript code example. Try running the code directly to see how it works.

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

All pages generated as HTML at build time.

8-2. Hybrid (Partial SSR)

Here’s a simple JavaScript code example. Try running the code directly to see how it works.

export default defineConfig({
  output: 'hybrid',
  adapter: cloudflare(), // or node(), vercel()
});

Here’s an implementation example using TypeScript. Perform tasks efficiently with async processing. Try running the code directly to see how it works.

// 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

Here’s a simple JavaScript code example. Try running the code directly to see how it works.

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

9. Deployment (Cloudflare Pages)

9-1. GitHub Integration

  1. Cloudflare DashboardPagesCreate 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

Here’s a detailed implementation using YAML. Please review the code to understand the role of each part.

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, refer to Complete Cloudflare Pages Guide.


10. Practical Tips

10-1. Build Performance

Large Pages (1,000+):

Here’s an implementation example using JavaScript. Try running the code directly to see how it works.

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

OG Image Caching:

Here’s an implementation example using JavaScript. Import necessary modules, process data with loops, handle branching with conditionals. Please review the code to understand the role of each part.

// 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

Here’s an implementation example using TypeScript. Try running the code directly to see how it works.

// 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);
}
// src/pages/blog/[slug].astro
const readingTime = calculateReadingTime(post.body);

Here’s a detailed implementation using TypeScript. Implement logic through functions, process data with loops. Please review the code to understand the role of each part.

// 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.

Here’s an implementation example using JavaScript. Try running the code directly to see how it works.

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

10-5. Comments (Giscus)

Here’s an implementation example using Astro. Perform tasks efficiently with async processing. Please review the code to understand the role of each part.

<!-- 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>

11. Advanced Features

11-1. View Transitions (Page Transition Animations)

Here’s an implementation example using Astro. Import necessary modules. Please review the code to understand the role of each part.

---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>

Smooth transition effects when navigating pages.

11-2. Middleware

Here’s an implementation example using TypeScript. Import necessary modules, perform tasks efficiently with async processing. Please review the code to understand the role of each part.

// 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

Here’s an implementation example using TypeScript. Try running the code directly to see how it works.

// PUBLIC_ prefix allows client-side access
const siteUrl = import.meta.env.PUBLIC_SITE_URL;

// PRIVATE_ is server-only
const apiKey = import.meta.env.PRIVATE_API_KEY;

12. 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

Checklist

Project Setup:

  • Define Content Collections schema
  • Design tag/series structure
  • Configure RSS/Sitemap
  • OG image generation script

Content:

  • Markdown template (frontmatter)
  • Code block styling
  • Auto-generate table of contents
  • Related posts logic

Deployment:

  • Configure environment variables
  • Optimize build cache
  • Custom domain
  • Analytics integration

Next Steps

Articles to read with Astro blog:

References:

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3