How to Build a Personal Blog Site with Astro and Deploy to GitHub Pages
If you’re looking for a fast, modern way to build a personal blog, Astro is one of the best options available today. It ships zero JavaScript by default, builds in seconds, and gives you full control over your site without the complexity of a full framework.
I recently migrated my blog from Jekyll to Astro and the difference was immediate — faster builds, cleaner code, and a much better developer experience. In this post, I’ll walk you through how to build a blog like mine with dark mode, full-text search, SEO optimization, and automatic deployment to GitHub Pages.
You can see the final result at islammdshariful.github.io.
Table of Contents
- What You’ll Build
- Project Setup
- Content Collections
- Building the Layout
- Blog Posts and Dynamic Routes
- Adding Dark Mode
- Adding Search with Pagefind
- Smooth Page Transitions
- SEO, Sitemap, and RSS
- Deploying to GitHub Pages
- Wrapping Up
What You’ll Build
A fully-featured personal blog with:
- Zero JavaScript by default — Astro only ships JS where you explicitly need it
- Dark mode with localStorage persistence and syntax highlighting that adapts
- Full-text search powered by Pagefind (build-time indexing, no external service)
- SEO optimization — meta tags, Open Graph, Twitter Cards, custom sitemap with priority and changefreq
- RSS feed for subscribers
- Automatic deployment to GitHub Pages via GitHub Actions
- Smooth page transitions using Astro’s built-in View Transitions API
- Fast builds — the entire site builds in under 2 seconds
Project Setup
Start by creating a new Astro project:
npm create astro@latest -- --template minimal my-blog
cd my-blog
Install the dependencies you’ll need:
npm install @astrojs/rss sass
npm install -D pagefind
Update your package.json scripts to include Pagefind in the build step:
{
"scripts": {
"dev": "astro dev",
"build": "astro build && npx pagefind --site dist",
"preview": "astro preview"
}
}
Configure Astro in astro.config.mjs with your site URL and syntax highlighting themes:
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://yourusername.github.io',
markdown: {
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
},
},
});
The dual theme configuration is key — Astro’s Shiki highlighter outputs CSS custom properties that let you switch between light and dark syntax themes with just a CSS class toggle, which we’ll use for dark mode later.
Content Collections
Astro’s content collections give you type-safe access to your blog posts. Create src/content.config.ts:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
author: z.string(),
categories: z.array(z.string()),
tags: z.array(z.string()),
featured: z.boolean().default(false),
description: z.string().optional(),
image: z.string().optional(),
date: z.coerce.date(),
}),
});
export const collections = { blog };
Now create your first blog post at src/content/blog/my-first-post.md:
---
title: "My First Post"
author: yourname
categories: [General]
tags: [hello-world]
featured: true
description: "This is my first blog post built with Astro."
date: 2026-04-03
---
Hello, world! This is my first Astro blog post.
The z.coerce.date() schema is important — it converts the YAML date string into a proper JavaScript Date object, so you can sort and format dates easily.
Building the Layout
Create a base layout at src/layouts/BaseLayout.astro that every page will use. Here’s the key structure:
---
import '../styles/screen.css';
interface Props {
title: string;
description?: string;
isPost?: boolean;
}
const { title, description, isPost = false } = Astro.props;
const siteTitle = 'My Blog';
const siteDescription = 'My personal blog about things I build.';
const metaDescription = description || siteDescription;
const currentYear = new Date().getFullYear();
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | {siteTitle}</title>
<meta name="description" content={metaDescription} />
<!-- Open Graph -->
<meta property="og:title" content={`${title} | ${siteTitle}`} />
<meta property="og:description" content={metaDescription} />
<meta property="og:type" content={isPost ? 'article' : 'website'} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${title} | ${siteTitle}`} />
<meta name="twitter:description" content={metaDescription} />
<link rel="alternate" type="application/rss+xml" title={siteTitle} href="/rss.xml" />
</head>
<body>
<nav><!-- Your navigation here --></nav>
<main>
<slot />
</main>
<footer>
<p>© {currentYear} {siteTitle}</p>
</footer>
</body>
</html>
The <slot /> is where page content gets injected — it’s Astro’s equivalent of {{ content }} in Jekyll or {children} in React.
The isPost prop controls layout differences between pages and blog posts. For example, you might show a site heading on the homepage but hide it on post pages.
CSS imports in the frontmatter block (import '../styles/screen.css') are processed by Vite at build time — they get bundled, minified, and scoped automatically. No need for a separate CSS pipeline.
Blog Posts and Dynamic Routes
Create a dynamic route at src/pages/[slug].astro that generates a page for every blog post:
---
import type { GetStaticPaths } from 'astro';
import { getCollection, render } from 'astro:content';
import PostLayout from '../layouts/PostLayout.astro';
export const getStaticPaths = (async () => {
const allPosts = (await getCollection('blog'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return allPosts.map((post, index) => {
const prevPost = index < allPosts.length - 1
? { title: allPosts[index + 1].data.title, url: `/${allPosts[index + 1].id}/` }
: null;
const nextPost = index > 0
? { title: allPosts[index - 1].data.title, url: `/${allPosts[index - 1].id}/` }
: null;
return {
params: { slug: post.id },
props: { post, prevPost, nextPost },
};
});
}) satisfies GetStaticPaths;
const { post, prevPost, nextPost } = Astro.props;
const { Content } = await render(post);
---
<PostLayout
title={post.data.title}
date={post.data.date}
categories={post.data.categories}
tags={post.data.tags}
description={post.data.description}
prevPost={prevPost}
nextPost={nextPost}
>
<Content />
</PostLayout>
A few things to notice:
getStaticPathsruns at build time and generates a page for each post. This is how Astro handles dynamic routes in a static site.- Prev/Next navigation is computed from the sorted post array — the adjacent posts become navigation links at the bottom of each post.
render(post)converts the Markdown content into an Astro component you can render with<Content />.
For the homepage, fetch all posts and display them with cards:
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
const allPosts = (await getCollection('blog'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const featuredPosts = allPosts.filter(post => post.data.featured);
---
<BaseLayout title="Home">
<section>
<h2>Featured</h2>
{featuredPosts.map(post => (
<a href={`/${post.id}/`}>{post.data.title}</a>
))}
</section>
<section>
<h2>All Posts</h2>
{allPosts.map(post => (
<article>
<a href={`/${post.id}/`}>{post.data.title}</a>
<p>{post.data.description}</p>
<time>{post.data.date.toLocaleDateString()}</time>
</article>
))}
</section>
</BaseLayout>
For pagination, Astro has a built-in paginate() function. Create src/pages/blog/[...page].astro and use it in getStaticPaths to split posts across multiple pages.
Adding Dark Mode
Dark mode uses a simple approach: toggle a CSS class on <body> and persist the choice in localStorage.
Add this script to your BaseLayout (use is:inline so Astro doesn’t bundle it):
<script is:inline>
(function () {
var toggle = document.getElementById('darkModeToggle');
var body = document.body;
if (localStorage.getItem('darkMode') === 'true') {
body.classList.add('dark-mode');
}
toggle.addEventListener('click', function () {
body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', body.classList.contains('dark-mode'));
});
})();
</script>
Then in your CSS, prefix dark mode styles with body.dark-mode:
body.dark-mode {
background-color: #1a1a1a;
color: #e0e0e0;
}
body.dark-mode .card {
background: rgba(40, 40, 40, 0.7);
border-color: rgba(255, 255, 255, 0.08);
}
Making Code Blocks Theme-Aware
This is the part most tutorials miss. Astro’s Shiki highlighter outputs CSS custom properties (--shiki-dark, --shiki-dark-bg) for the dark theme. You need to activate them when your dark mode class is present:
body.dark-mode .astro-code,
body.dark-mode .astro-code span {
color: var(--shiki-dark) !important;
background-color: transparent !important;
}
body.dark-mode .astro-code {
background-color: var(--shiki-dark-bg) !important;
}
This one CSS rule makes every code block on your site automatically switch between github-light and github-dark themes when the user toggles dark mode. No JavaScript required for the switch itself.
Adding Search with Pagefind
Pagefind is a static search library that indexes your site at build time. It produces a tiny search index that runs entirely in the browser — no server, no API keys, no external service.
We already added Pagefind to the build command earlier:
"build": "astro build && npx pagefind --site dist"
For the search UI, use Pagefind’s JavaScript API to build a custom interface that matches your site’s design:
<form id="pagefind-form">
<input type="text" id="pagefind-input" placeholder="Search..." />
</form>
<div id="pagefind-results"><ul></ul></div>
The search script lazy-loads Pagefind on first use:
var pagefind = null;
async function loadPagefind() {
if (!pagefind) {
pagefind = await import('/pagefind/pagefind.js');
await pagefind.init();
}
return pagefind;
}
Then on input, debounce the search and render results using DOM methods:
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async function() {
var query = input.value.trim();
if (!query) return;
var pf = await loadPagefind();
var search = await pf.search(query);
var results = await Promise.all(
search.results.slice(0, 10).map(r => r.data())
);
// Render results to the DOM
results.forEach(function(result) {
// Create list items with title, URL, excerpt
});
}, 200);
});
Pagefind only works after a production build (it needs the built HTML to index). During development, the search will show a console warning — that’s expected.
Smooth Page Transitions
Astro has built-in support for the View Transitions API, which gives your site smooth crossfade animations between pages instead of hard reloads — making it feel like a single-page app while staying fully static.
Add the import and component to your BaseLayout:
---
import { ViewTransitions } from 'astro:transitions';
---
<head>
<!-- your other head content -->
<ViewTransitions />
</head>
That’s it — all page navigation will now animate smoothly.
One gotcha: inline scripts that set up event listeners (like the dark mode toggle) need to re-initialize after each transition. Astro fires an astro:after-swap event for this:
function initDarkMode() {
var toggle = document.getElementById('darkModeToggle');
var body = document.body;
if (localStorage.getItem('darkMode') === 'true') {
body.classList.add('dark-mode');
}
toggle.addEventListener('click', function () {
body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', body.classList.contains('dark-mode'));
});
}
initDarkMode();
document.addEventListener('astro:after-swap', initDarkMode);
This ensures dark mode state persists correctly across page transitions.
SEO, Sitemap, and RSS
Custom Sitemap
Instead of using a plugin, create a custom sitemap at src/pages/sitemap.xml.ts that includes priority, changefreq, and lastmod:
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
export async function GET(context: APIContext) {
const site = context.site!.href.replace(/\/$/, '');
const posts = (await getCollection('blog'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = [
{ loc: '/', changefreq: 'daily', priority: '1.0', lastmod: new Date().toISOString() },
{ loc: '/about/', changefreq: 'monthly', priority: '0.5', lastmod: new Date().toISOString() },
];
const postEntries = posts.map(post => ({
loc: `/${post.id}/`,
changefreq: 'weekly',
priority: '0.8',
lastmod: post.data.date.toISOString(),
}));
const allEntries = [...pages, ...postEntries];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allEntries.map(entry => ` <url>
<loc>${site}${entry.loc}</loc>
<lastmod>${entry.lastmod}</lastmod>
<changefreq>${entry.changefreq}</changefreq>
<priority>${entry.priority}</priority>
</url>`).join('\n')}
</urlset>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
});
}
RSS Feed
Create src/pages/rss.xml.ts using the official @astrojs/rss package:
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = (await getCollection('blog'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return rss({
title: 'My Blog',
description: 'My personal blog.',
site: context.site!,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
description: post.data.description || '',
link: `/${post.id}/`,
categories: [...post.data.categories, ...post.data.tags],
})),
});
}
robots.txt
Add public/robots.txt to point crawlers to your sitemap:
User-agent: *
Allow: /
Sitemap: https://yourusername.github.io/sitemap.xml
Deploying to GitHub Pages
The deployment uses GitHub Actions to build the Astro site and push the output to your username.github.io repository.
Create .github/workflows/pages.yml:
name: Deploy Astro to GitHub Pages
on:
push:
branches: ["main"]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.DEPLOY_TOKEN }}
external_repository: yourusername/yourusername.github.io
publish_branch: main
publish_dir: ./dist
To set this up:
- Create a personal access token on GitHub (Settings > Developer Settings > Personal Access Tokens) with
reposcope - Add it as a secret in your blog source repo (Settings > Secrets >
DEPLOY_TOKEN) - Set GitHub Pages source on your
.github.iorepo to “Deploy from a branch” (main, root)
Every push to your source repo will now automatically build and deploy the site. The entire build takes under 30 seconds.
Wrapping Up
Here’s what you get with this setup:
- A blog that builds in under 2 seconds
- Zero JavaScript shipped to the browser (unless you explicitly add it)
- Dark mode that remembers the user’s preference
- Full-text search with no external dependencies
- Smooth page transitions that feel like a SPA
- SEO-ready with meta tags, sitemap, and RSS
- Automatic deployment on every push
If you found this helpful or have questions, feel free to reach out on LinkedIn.