Article
The smallest upgrade path from blog to technology platform (4): Astro + Content Collections practical guide
Convert the design concepts from the first three articles into code. This article is a complete technical implementation guide, including all codes such as project structure, Schema design, dynamic routing, search integration, etc.
Writing Statement This article is based on the complete source code of the author using Astro to build a blog. All codes come from the production environment and can be directly referenced or modified for use.
Beginning: Minimum viable solution for converting design into code
The first three articles discussed the concepts of thematicization, labeling systems, and homepage design. This article solves a problem: **How to completely implement these designs with Astro? **
I will start from an empty project and build a blog system with the following functions step by step:
- Topics content management
- Organization and classification of articles (Posts)
- Guides sequence
- Tags system
- Platform homepage
- Basic search
Technology stack: Astro 5.x + TypeScript + Tailwind CSS
Step 1: Project initialization and dependencies
1.1 Create Astro project
npm create astro@latest my-blog
cd my-blog
choose:
- Template: Empty
- TypeScript: Yes
- Dependencies: Install
1.2 Install necessary dependencies
# core dependencies
npm install @astrojs/tailwindcss @astrojs/mdx
# search feature, optional
npm install pagefind
# utility library
npm install date-fns
1.3 Configuration file
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
output: 'static',
integrations: [
tailwind(),
mdx(),
sitemap(),
],
});
Step 2: Content Collections Schema design
This is the core of the entire system. We define three content collections: topics, blogs, and guides.
2.1 Configure content collection
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
// topic collection
const topics = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
order: z.number().default(999),
featured: z.boolean().default(false),
subtopics: z.array(z.string()).optional(),
recommendedReading: z.array(z.string()).optional(),
}),
});
// blog post collection
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
topic: z.string(), // owning topic
tags: z.array(z.string()).default([]),
category: z.enum(['interpretation', 'guide', 'tutorial', 'reference']),
featured: z.boolean().default(false),
series: z.object({
name: z.string(),
order: z.number(),
}).optional(),
}),
});
// guide collection
const guides = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
topic: z.string(),
order: z.number(),
featured: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { topics, blog, guides };
2.2 Directory structure
src/
├── content/
│ ├── blog/
│ │ ├── post-1.md
│ │ └── post-2.mdx
│ ├── guides/
│ │ ├── guide-1.md
│ │ └── guide-2.md
│ └── topics/
│ ├── topic-1.md
│ └── topic-2.md
├── lib/
│ └── content.ts # content query utilities
├── pages/
│ ├── index.astro # Home page
│ ├── blog/
│ │ ├── index.astro
│ │ └── [slug].astro
│ ├── guides/
│ │ ├── index.astro
│ │ └── [slug].astro
│ ├── topics/
│ │ ├── index.astro
│ │ └── [slug].astro
│ └── tags/
│ ├── index.astro
│ └── [tag].astro
└── components/
├── ContentCard.astro
├── TopicCard.astro
└── SectionHeader.astro
Step Three: Content Query Tool Library
3.1 Basic query functions
// src/lib/content.ts
import { getCollection, type CollectionEntry } from 'astro:content';
// type exports
export type Post = CollectionEntry<'blog'>;
export type Guide = CollectionEntry<'guides'>;
export type Topic = CollectionEntry<'topics'>;
// get all posts, reverse chronological
export async function getAllPosts(): Promise<Post[]> {
return (await getCollection('blog'))
.filter(post => !post.id.startsWith('_drafts/'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
// get latest posts
export async function getRecentPosts(limit = 5): Promise<Post[]> {
return (await getAllPosts()).slice(0, limit);
}
// get featured posts
export async function getFeaturedPosts(limit = 6): Promise<Post[]> {
return (await getAllPosts())
.filter(post => post.data.featured)
.slice(0, limit);
}
// get posts under a topic
export async function getPostsByTopic(topicSlug: string): Promise<Post[]> {
return (await getAllPosts())
.filter(post => normalizeSlug(post.data.topic) === topicSlug);
}
// get all guides
export async function getAllGuides(): Promise<Guide[]> {
return (await getCollection('guides'))
.sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}
// get all topics
export async function getAllTopics(): Promise<Topic[]> {
return (await getCollection('topics'))
.sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}
// topic title map
export async function getTopicTitleMap(): Promise<Map<string, string>> {
const topics = await getAllTopics();
return new Map(topics.map(t => [normalizeSlug(t.id), t.data.title]));
}
// helper: normalize slug
function normalizeSlug(id: string): string {
return id.replace(/\.md$/, '');
}
3.2 Tag statistics tool
// src/lib/taxonomy.ts
import { getAllPosts, type Post } from './content';
export interface TagCount {
tag: string;
count: number;
}
// count tag usage frequency
export async function getTagCounts(): Promise<TagCount[]> {
const posts = await getAllPosts();
const counts = new Map<string, number>();
posts.forEach(post => {
post.data.tags?.forEach(tag => {
counts.set(tag, (counts.get(tag) || 0) + 1);
});
});
return Array.from(counts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
// get all tags for a post
export function getPostTags(post: Post): string[] {
return post.data.tags || [];
}
// format tag display
export function formatTagLabel(tag: string): string {
return tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
Step 4: Dynamic routing implementation
4.1 Blog article details page
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import { getTopicTitleById } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';
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();
const topicTitle = await getTopicTitleById(post.data.topic);
---
<BaseLayout
title={post.data.title}
description={post.data.description}
>
<article class="max-w-3xl mx-auto">
<!-- article header -->
<header class="mb-8">
<div class="flex items-center gap-2 text-sm text-slate-500 mb-4">
<a href={`/topics/${post.data.topic}`} class="hover:text-slate-700">
{topicTitle}
</a>
<span>•</span>
<time>{post.data.pubDate.toLocaleDateString('zh-CN')}</time>
</div>
<h1 class="text-3xl font-bold mb-4">{post.data.title}</h1>
<p class="text-lg text-slate-600">{post.data.description}</p>
<!-- tags -->
<div class="flex flex-wrap gap-2 mt-4">
{post.data.tags.map(tag => (
<a
href={`/tags/${tag}`}
class="px-3 py-1 text-sm bg-slate-100 rounded-full hover:bg-slate-200"
>
{tag}
</a>
))}
</div>
</header>
<!-- article content -->
<div class="prose prose-slate max-w-none">
<Content />
</div>
<!-- article footer navigation -->
<footer class="mt-12 pt-8 border-t">
<a href="/blog" class="text-slate-600 hover:text-slate-900">
← back to article list
</a>
</footer>
</article>
</BaseLayout>
4.2 Topic details page
---
// src/pages/topics/[slug].astro
import { getCollection } from 'astro:content';
import { getPostsByTopic, getGuidesByTopic } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';
export async function getStaticPaths() {
const topics = await getCollection('topics');
return topics.map(topic => ({
params: { slug: topic.slug },
props: { topic },
}));
}
const { topic } = Astro.props;
const { Content } = await topic.render();
const [posts, guides] = await Promise.all([
getPostsByTopic(topic.slug),
getGuidesByTopic(topic.slug),
]);
---
<BaseLayout
title={topic.data.title}
description={topic.data.description}
>
<div class="max-w-4xl mx-auto">
<!-- topic header -->
<header class="mb-12">
<h1 class="text-4xl font-bold mb-4">{topic.data.title}</h1>
<p class="text-xl text-slate-600">{topic.data.description}</p>
<div class="mt-4 text-sm text-slate-500">
{posts.length} posts / {guides.length} guides
</div>
</header>
<!-- topic intro, optional -->
<div class="prose prose-slate mb-12">
<Content />
</div>
<!-- guide list -->
{guides.length > 0 && (
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Guides</h2>
<div class="space-y-4">
{guides.map(guide => (
<ContentCard
href={`/guides/${guide.slug}`}
title={guide.data.title}
description={guide.data.description}
meta={`Guides ${guide.data.order}`}
/>
))}
</div>
</section>
)}
<!-- Postlist -->
<section>
<h2 class="text-2xl font-bold mb-6">Post</h2>
<div class="space-y-6">
{posts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
tags={post.data.tags}
/>
))}
</div>
</section>
</div>
</BaseLayout>
4.3 Tab page
---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import { formatTagLabel, getTagCounts } from '../../lib/taxonomy';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';
export async function getStaticPaths() {
const tagCounts = await getTagCounts();
// generate 2 Posttags
return tagCounts
.filter(({ count }) => count >= 2)
.map(({ tag }) => ({
params: { tag },
props: { tag },
}));
}
const { tag } = Astro.props;
const posts = await getCollection('blog', post =>
post.data.tags?.includes(tag)
);
// sort in reverse chronological order
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title={`tags: ${formatTagLabel(tag)}`}>
<div class="max-w-4xl mx-auto">
<header class="mb-8">
<h1 class="text-3xl font-bold">{formatTagLabel(tag)}</h1>
<p class="text-slate-600 mt-2">{posts.length} posts</p>
</header>
<div class="space-y-6">
{posts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
/>
))}
</div>
</div>
</BaseLayout>
Step 5: Implementation of platform-based homepage
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import {
getRecentPosts,
getFeaturedPosts,
getAllTopics,
getTopicTitleMap,
} from '../lib/content';
import { getTagCounts, formatTagLabel } from '../lib/taxonomy';
import ContentCard from '../components/ContentCard.astro';
import TopicCard from '../components/TopicCard.astro';
import SectionHeader from '../components/SectionHeader.astro';
const [
recentPosts,
featuredPosts,
topics,
tagCounts,
topicTitleMap,
] = await Promise.all([
getRecentPosts(4),
getFeaturedPosts(6),
getAllTopics(),
getTagCounts(),
getTopicTitleMap(),
]);
---
<BaseLayout title="Home page">
<div class="space-y-16">
<!-- Hero area -->
<section class="hero">
<h1>Technical topic knowledge base</h1>
<p>Covers backend engineering, AI engineering, distributed systems, and related domains</p>
<div class="actions">
<a href="/topics" class="btn-primary">Browse topics</a>
<a href="/blog" class="btn-secondary">View articles</a>
</div>
</section>
<!-- Featured content -->
<section>
<SectionHeader
title="Featured content"
description="Start with these representative pieces"
actionHref="/blog"
actionLabel="Browse all"
/>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredPosts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
topicLabel={topicTitleMap.get(post.data.topic)}
tags={post.data.tags.slice(0, 3)}
/>
))}
</div>
</section>
<!-- Topic entry -->
<section>
<SectionHeader
title="Topic entry"
description="Browse content by technical domain"
actionHref="/topics"
actionLabel="View all"
/>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{topics.map(topic => (
<TopicCard
href={`/topics/${topic.slug}`}
title={topic.data.title}
description={topic.data.description}
/>
))}
</div>
</section>
<!-- Recent updates -->
<section>
<SectionHeader
title="recentUpdate"
description="Recently published content"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{recentPosts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
topicLabel={topicTitleMap.get(post.data.topic)}
/>
))}
</div>
</section>
<!-- quick index -->
<section>
<h2>quick index</h2>
<div class="flex flex-wrap gap-2">
{tagCounts.slice(0, 15).map(({ tag, count }) => (
<a
href={`/tags/${tag}`}
class="px-4 py-2 bg-slate-100 rounded-full hover:bg-slate-200"
>
{formatTagLabel(tag)} ({count})
</a>
))}
</div>
<a href="/tags" class="text-slate-600 hover:text-slate-900 mt-4 inline-block">
View all tags →
</a>
</section>
</div>
</BaseLayout>
Step 6: Search function integration (optional)
6.1 Install Pagefind
npm install pagefind
6.2 Build script configuration
// package.json
{
"scripts": {
"build": "astro build && pagefind --site dist",
"dev": "astro dev"
}
}
6.3 Search page
---
// src/pages/search.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Search">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-8">Search</h1>
<!-- Pagefind Search -->
<div id="search" class="pagefind-ui"></div>
</div>
<script is:inline>
// Pagefind will be injected automatically
</script>
</BaseLayout>
Step 7: Deploy to GitHub Pages
7.1 GitHub Actions configuration
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: withastro/action@v2
with:
node-version: 20
package-manager: npm
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v3
7.2 Astro configuration adaptation
// astro.config.mjs
export default defineConfig({
site: 'https://yourusername.github.io',
base: '/your-repo-name', // if using project pages
// ...
});
Performance optimization suggestions
Image optimization
Using Astro’s Image component:
---
import { Image } from 'astro:assets';
import myImage from '../assets/image.jpg';
---
<Image src={myImage} alt="description" width={800} height={400} />
Partially hydrated
Use client:* directives for interactive components:
<SearchComponent client:load />
Preload key pages
<link rel="prefetch" href="/blog" />
<link rel="prefetch" href="/topics" />
Conclusion: From code to content
After completing the technical implementation, your blog already has all the infrastructure of the technical platform:
- ✅ Thematic content organization
- ✅ Tag classification system
- ✅Platform home page
- ✅ Content discovery path
- ✅Search function
**But remember: technology is just the carrier, content is the value. **
This series discusses not only how to build a blog system, but also how to rethink the way content is organized - from “file pile” to “knowledge base”, from “time flow” to “topic space”.
I hope this series will help you upgrade your blog. The complete source code can be found at GitHub. Reference and communication are welcome.
Series of articles
- From “file pile” to “topicization” - the first principle of blog content organization
- The Art of Designing Tags and Topics—How to Build a Content Taxonomy That’s Not Cluttered
- Building a platform-based homepage - allowing readers to go from “seeing” to “discovering”
- Astro + Content Collections Practical Guide ← Article
Reference resources
Series context
You are reading: Minimal upgrade path from blog to technology platform
This is article 4 of 4. Reading progress is stored only in this browser so the full series page can resume from the right entry.
Series Path
Current series chapters
Chapter clicks store reading progress only in this browser so the series page can resume from the right entry.
- The minimum upgrade path from blog to technology platform (1): from 'file pile' to 'thematic' When you have more than 20 blog posts, readers start to get lost in time. This article shares a practical experience: why thematicization is the first step in blog upgrade, and how to judge whether you have reached the moment where you need to upgrade.
- The smallest upgrade path from blog to technology platform (2): The design art of labels and topics What is the difference between topics and tags? Why is it harder to find content when there are too many tags? This article dismantles the three most common misunderstandings in content taxonomy and shares a practical 'three-tier tag system' design method.
- The smallest upgrade path from blog to technology platform (3): Build a platform-based homepage - let readers go from 'seeing' to 'discovering' Thematicization solves the problem of content attribution, but what should readers see when they open the homepage? This article shares how to design a 'content discovery' homepage, rather than a simple time flow list.
- The smallest upgrade path from blog to technology platform (4): Astro + Content Collections practical guide Convert the design concepts from the first three articles into code. This article is a complete technical implementation guide, including all codes such as project structure, Schema design, dynamic routing, search integration, etc.
Reading path
Continue along this topic path
Follow the recommended order for Content Platform Engineering instead of jumping through random articles in the same topic.
Next step
Go deeper into this topic
If this article is useful, continue from the topic page or subscribe to follow later updates.
Loading comments...
Comments and discussion
Sign in with GitHub to join the discussion. Comments are synced to GitHub Discussions