본문으로 건너뛰기 Building a Tech Blog with Astro | Content Collections· MDX

Building a Tech Blog with Astro | Content Collections· MDX

Building a Tech Blog with Astro | Content Collections· MDX

이 글의 핵심

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

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

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

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

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

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

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

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

// 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,
---
}));
<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
// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

Automatically generates search index after build.

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

7. 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={`/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)

// 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: 'hybrid',
  adapter: cloudflare(), // or node(), vercel()
});
// 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

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

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+):

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

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

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


Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

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

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers Astro, Blog, JAMstack, MDX, Content Collections, SEO, Static Site, SSG, SSR.