How I Built My Blog with Astro: A Bug Cursor, a Feeding Ground and 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 builds in seconds, it ships JavaScript only where you ask for it, and it gives you full control over your site without the weight of a full framework.

I moved my blog from Jekyll to Astro, and then I made it a little strange. The cursor is a bug. The home page is a feeding ground, and every post is food. A post that you have read gets a bite taken out of it. There is no top bar and no footer, only a small leaf-shaped dock at the bottom.

In this post, I walk through how it is built: the Astro basics first, then the playful parts, then search, dark mode, SEO and the deploy to GitHub Pages.

You can see the result at islammdshariful.github.io.


Table of Contents

  1. What You’ll Build
  2. Project Setup
  3. Content Collections
  4. Building the Layout
  5. Blog Posts and Dynamic Routes
  6. Making It Playful
  7. Adding Dark Mode
  8. Adding Search with Pagefind
  9. Smooth Page Transitions
  10. SEO, Sitemap, and RSS
  11. Deploying to GitHub Pages
  12. Wrapping Up

What You’ll Build

A personal blog with:

  • Very little JavaScript — Astro ships JS only where you add it, and it bundles and minifies what you do add
  • A bug for a cursor — it turns as it walks, moves its legs, and wiggles its antennae near food
  • A feeding ground for a home page — every post is food; the newest is a fresh strawberry
  • Read posts that stay “eaten” — remembered in the visitor’s own browser, with no account and no server
  • A floating dock in place of a top bar and a footer: search, home and a day/night toggle
  • Dark mode that remembers the visitor’s choice, with syntax highlighting that follows it
  • Full-text search powered by Pagefind (built at build time, no outside service)
  • SEO basics — meta tags, Open Graph, a real link-preview image, a sitemap and an RSS feed
  • Smooth page transitions with Astro’s View Transitions
  • Automatic deploys to GitHub Pages with GitHub Actions

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. My site has no top bar and no footer: everything a visitor needs sits in one small dock at the bottom of the screen.

---
import '../styles/screen.css';
import { ViewTransitions } from 'astro:transitions';
import Dock from '../components/Dock.astro';

interface Props {
  title: string;
  description?: string;
  image?: string;
  isPost?: boolean;
}

const { title, description, image, isPost = false } = Astro.props;
const siteTitle = 'My Blog';
const metaDescription = description || "A hungry bug's blog about QA, automation and AI.";
// Link previews need a full URL, and a real image works much better than a favicon.
const metaImage = new URL(image || '/assets/images/og-default.png', Astro.site ?? Astro.url).href;
---

<!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} />
    <meta property="og:title" content={`${title} | ${siteTitle}`} />
    <meta property="og:description" content={metaDescription} />
    <meta property="og:image" content={metaImage} />
    <meta name="twitter:card" content="summary_large_image" />
    <link rel="icon" href="/assets/images/favicon.svg" type="image/svg+xml" />
    <link rel="alternate" type="application/rss+xml" title={siteTitle} href="/rss.xml" />
    <ViewTransitions />
</head>
<body class={isPost ? 'layout-post' : ''}>
    {!isPost && <h1 class="sr-only">{siteTitle}</h1>}
    <main>
        <slot />
    </main>
    <Dock currentPath={Astro.url.pathname} />
    <script>
      import '../scripts/bug-cursor';
    </script>
</body>
</html>

The <slot /> is where page content gets injected — it’s Astro’s equivalent of {{ content }} in Jekyll or {children} in React.

The site title is still in the page, as a heading that only screen readers and search engines see. The page does not need it on screen, but they do.

CSS imports in the frontmatter block (import '../styles/screen.css') are processed by Vite at build time — they get bundled and minified automatically. The same happens to the <script> at the end: Astro bundles it as a module, so the cursor code ships minified and runs once per visit.


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:

  • getStaticPaths runs 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 home page, fetch all posts, newest first, and turn each one into food. The newest is always a strawberry. Older posts are a leaf or a cookie, chosen from a hash of the slug, so a post keeps its food forever, even when new posts arrive:

// src/lib/food.ts
export function hash(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

export function foodKind(slug: string, isNewest: boolean) {
  if (isNewest) return 'berry';
  return hash(slug) % 2 === 0 ? 'leaf' : 'cookie';
}

The same hash also picks where each food sits on the page, so it lands in the same spot on every visit. More about the feeding ground in the next section.

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. On my site that page is “the pantry”: every post in a row, with its food, its date and its reading time.


Making It Playful

This is the part that makes the site mine. None of it needs a framework: it is plain SVG, CSS and a few small scripts.

A bug for a cursor

The cursor is one SVG bug, the same drawing as the Home button in the dock. A small script moves it with the mouse and turns it toward the direction it walks. Two sets of legs take turns while it moves, so it looks like it walks:

document.addEventListener('pointermove', (e) => {
  x = e.clientX;
  y = e.clientY;
});

function tick(now: number) {
  const dx = x - px, dy = y - py;
  if (Math.hypot(dx, dy) > 0.6) {
    const target = Math.atan2(dy, dx) + Math.PI / 2;
    const diff = Math.atan2(Math.sin(target - ang), Math.cos(target - ang));
    ang += diff * 0.25;                                    // turn smoothly
    if (now - lastStep > 90) { frame ^= 1; lastStep = now; } // swap the legs
  }
  el.classList.toggle('step', frame === 1);
  el.style.transform = `translate(${x - 16}px, ${y - 14}px) rotate(${ang}rad)`;
  px = x; py = y;
  requestAnimationFrame(tick);
}

It runs only on devices with a real mouse, (hover: hover) and (pointer: fine). Phones keep their normal touch behaviour.

The feeding ground

On the home page, each post is a food on a dot grid. When the bug comes close to a food, the food grows, the bug’s antennae wiggle, and a label opens with the title, the description, the date and the reading time. A click takes a bite and opens the post. When the visitor stops moving, the freshest food wiggles to call the bug over.

On a phone there is no cursor, so the first tap opens the label and the second tap opens the post.

Eaten posts

Reading a post counts as eating it. The post page saves its slug in the visitor’s own browser:

const KEY = 'bug-food-eaten';

export function markEaten(slug: string) {
  try {
    const list = JSON.parse(localStorage.getItem(KEY) || '[]');
    if (!list.includes(slug)) {
      list.push(slug);
      localStorage.setItem(KEY, JSON.stringify(list));
    }
  } catch {
    /* private window or blocked storage: the page still works, it just forgets */
  }
}

The home page and the pantry read the same list, draw a bite out of each eaten food, and count what is left: “2 to eat · 1 eaten”. Nothing leaves the browser. There is no account, no cookie banner and no server.

A 404 page that eats the address

GitHub Pages serves 404.html for every address that does not exist. Mine shows the missing address in big letters, and the bug eats it, one letter at a time, with crumbs falling. Then it offers the three newest posts as food. Each letter is a span with its own animation delay:

.ch { display: inline-block; animation: bite .55s ease-in forwards; animation-delay: calc(900ms + var(--i) * 110ms); }
@keyframes bite { 40% { transform: scale(1.15) rotate(-6deg); } 100% { transform: translateY(6px) scale(.2); opacity: 0; } }

For visitors who turn off motion, prefers-reduced-motion switches all of this off, and the address is simply crossed out.


Adding Dark Mode

Dark mode uses a simple approach: toggle a CSS class on <body> and save the choice in localStorage. On my site the toggle lives in the dock, as a sun that sets and a moon that rises, with a small firefly that glows at night.

Apply the saved choice as early as you can, so the page does not flash white first. A one-line inline script right after <body> does it:

<script is:inline>try{if(localStorage.getItem('darkMode')==='true')document.body.classList.add('dark-mode')}catch(e){}</script>

The button itself only flips the class and saves it:

button.addEventListener('click', () => {
  const dark = !document.body.classList.contains('dark-mode');
  document.body.classList.toggle('dark-mode', dark);
  try { localStorage.setItem('darkMode', String(dark)); } catch { /* storage blocked */ }
});

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"

By default, Pagefind indexes every page, including the home page and the post lists. Then a search for “docker” returns the home page. Tell it to index only the post content, and which heading is the title:

<h1 class="posttitle" data-pagefind-meta="title">{title}</h1>
<div class="article-post" data-pagefind-body>
  <slot />
</div>

For the search UI, use Pagefind’s JavaScript API to build a custom interface that matches your site. Mine is a panel that opens from the dock, or when you press /: “What is the bug hungry for?”. Load Pagefind only when the panel opens. Keep the path in a variable with @vite-ignore, or Vite tries to bundle a file that exists only after the build:

let pagefind: any = null;

async function loadPagefind() {
  if (pagefind) return pagefind;
  const path = '/pagefind/pagefind.js';
  pagefind = await import(/* @vite-ignore */ path);
  await pagefind.init();
  return pagefind;
}

input.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const pf = await loadPagefind();
    const res = await pf.search(input.value);
    const rows = await Promise.all(res.results.slice(0, 8).map((r) => r.data()));
    // render rows: r.url, r.meta.title, r.excerpt
  }, 180);
});

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.

Two gotchas come with it:

  • A module <script> runs once per visit, not once per page. Put your setup code in a listener for astro:page-load, which fires on the first load and after every navigation.
  • The swap replaces the <body>. Anything you added to it by script, such as the cursor, and any class on it, such as dark-mode, is gone after a navigation. Put them back in an astro:after-swap listener, which runs before the new page is painted:
document.addEventListener('astro:after-swap', () => {
  applySavedTheme();   // put the dark-mode class back
  mountCursor();       // put the bug back
});
document.addEventListener('astro:page-load', wireUpPage);

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: '/blog/', changefreq: 'weekly', priority: '0.7', 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:

  1. Create a personal access token on GitHub (Settings > Developer Settings > Personal Access Tokens) with repo scope
  2. Add it as a secret in your blog source repo (Settings > Secrets > DEPLOY_TOKEN)
  3. Set GitHub Pages source on your .github.io repo 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 about a second, plus the search index
  • Only the JavaScript you actually add, bundled and minified
  • A bug cursor, a feeding ground and posts that stay eaten, with no server and no account
  • A dock in place of a top bar and a footer
  • Dark mode that remembers the visitor’s choice
  • Full-text search that indexes only the posts
  • Smooth page transitions that feel like a single-page app
  • SEO basics: meta tags, a link-preview image, a sitemap and RSS
  • An automatic deploy on every push

The Astro parts are the easy parts. The fun is in deciding what your site should feel like.

If you found this helpful or have questions, feel free to reach out on LinkedIn.

the end

If you found this post helpful, consider buying me a coffee. It keeps me writing!