Creating This Blog - Part 1
How to build an Astro blog with Nix flake and direnv development environment
A humble tutorial for my humble blog. There are many
like it, but this one is mine! I’m using Astro as
my static site generator, hosting on GitHub pages,
and setting up my build environment using Nix flakes
and direnv.
Why Astro?
- I’ve already tried Jekyll and Hugo and itching for something new
- I can’t keep avoiding JavaScript (as much as I’d like to)
- I like the idea of modern front-end framework support and
reusable components
- Let’s be honest, Claude is going to do all my front-end work because I hate doing front-end
Why GitHub pages?
- Mostly because it’s free and it works
- Everyone else is doing it and sometimes I like being a lemming (pls accept me)
Why Nix flakes and direnv?
- I like reproducibility and declarative things
- I develop across multiple machines and want consistency
- Reduced setup friction as a result
- Totally optional, but even for a single developer, solves the “it works on my machine” problem better than Docker, in my experience
Prerequisites
- Nix with flakes enabled
- direnv
- A custom domain that you own for your blog
There are many ways to get the Nix package manager installed on your system, if you aren’t already using NixOS or nix-darwin. For now, these will be beyond the scope of this tutorial (I may write one in the future!). However, I’ll include some helpful links below to get started for those of you who don’t already have a Nix environment set up:
- macOS: https://nixcademy.com/posts/nix-on-macos/
- Determinate Systems’ Zero-to-Nix: https://zero-to-nix.com/start/
Project Setup
Initialize Astro Project
npm create astro@latest blog
cd blog
Select:
- “Just the basics”
- TypeScript variant
Create Nix Flake
Create flake.nix:
{
description = "Astro blog development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
blog-scripts = pkgs.writeScriptBin "blog" ''
#!/usr/bin/env bash
case "$1" in
"new-post")
if [ -z "$2" ]; then
echo "Usage: blog new-post 'Post Title'"
exit 1
fi
slug=$(echo "$2" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
date=$(date +%Y-%m-%d)
file="src/content/blog/$slug.md"
cat > "$file" << EOF
---
title: '$2'
description: 'Description for $2'
pubDate: $date
---
# $2
Your content here...
EOF
echo "Created new post: $file"
;;
"dev")
npm run dev
;;
"build")
npm run build
;;
"preview")
npm run preview
;;
*)
echo "Available commands:"
echo " blog new-post 'Title' - Create a new blog post"
echo " blog dev - Start development server"
echo " blog build - Build for production"
echo " blog preview - Preview production build"
;;
esac
'';
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
git
jq
curl
tree
blog-scripts
];
shellHook = ''
echo "🚀 Astro blog development environment loaded!"
echo "Node version: $(node --version)"
echo "NPM version: $(npm --version)"
echo ""
blog
echo ""
if [ ! -d "node_modules" ]; then
echo "Installing dependencies..."
npm install
fi
'';
NODE_ENV = "development";
};
});
}
Configure direnv
Create .envrc:
#!/usr/bin/env bash
use flake
dotenv_if_exists .env.local
echo "✅ Direnv loaded successfully"
Run:
direnv allow
Lock the Flake
# Generate flake.lock for reproducible builds
nix flake lock
This creates a flake.lock file that pins exact versions of all dependencies.
Configure Astro
Update astro.config.mjs:
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
integrations: [mdx(), sitemap()],
});
If you don’t have a custom domain yet, your Astro config will look something like this:
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourusername.github.io', // Replace with your GitHub username
base: '/my-blog', // Replace with your repository name
integrations: [mdx(), sitemap()],
});
NOTE: if you are not using a custom domain, your routes and base path will be different
from those shown below; adjust accordingly. Additionally, the base property will need to
match your GitHub repository name exactly for routing to work properly.
Install integrations:
npm install @astrojs/mdx @astrojs/sitemap
Content Structure
Create 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.date(),
updatedDate: z.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
Create Layouts
Create src/layouts/BaseLayout.astro:
---
export interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description || "Blog"} />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<main>
<slot />
</main>
</body>
</html>
<style is:global>
html {
font-family: system-ui, sans-serif;
}
body {
margin: 0;
padding: 2rem;
line-height: 1.6;
}
main {
max-width: 800px;
margin: 0 auto;
}
</style>
Create src/layouts/BlogPost.astro:
---
import BaseLayout from './BaseLayout.astro';
import type { CollectionEntry } from 'astro:content';
type Props = CollectionEntry<'blog'>['data'];
const { title, description, pubDate, updatedDate, tags } = Astro.props;
---
<BaseLayout title={title} description={description}>
<article>
<header class="post-header">
<h1>{title}</h1>
<div class="post-meta">
<time datetime={pubDate.toISOString()}>
{pubDate.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
{updatedDate && (
<span class="updated">
Updated: {updatedDate.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div class="tags">
{tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
)}
</header>
<div class="post-content">
<slot />
</div>
</article>
</BaseLayout>
<style>
.post-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.post-meta {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
color: #666;
font-size: 0.9rem;
}
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.post-content h2,
.post-content h3 {
margin-top: 2rem;
margin-bottom: 1rem;
}
.post-content pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
</style>
Create Pages
This blog will have a main index, separate from the blog index, which will eventually contain links to my various online presences, resume, etc.
Create src/pages/index.astro:
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="Blog">
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</a>
</li>
))}
</ul>
</BaseLayout>
Create src/pages/blog/index.astro for the blog listing page:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const sortedPosts = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="All Blog Posts">
<h1>All Blog Posts</h1>
{sortedPosts.length > 0 ? (
<div class="posts-list">
{sortedPosts.map((post) => (
<article class="post-item">
<a href={`/blog/${post.slug}/`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<div class="post-meta">
<time>{post.data.pubDate.toLocaleDateString()}</time>
{post.data.tags && (
<div class="tags">
{post.data.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
)}
</div>
</a>
</article>
))}
</div>
) : (
<p>No posts published yet.</p>
)}
</BaseLayout>
<style>
h1 {
margin-bottom: 2rem;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 2rem;
}
.post-item {
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.post-item:last-child {
border-bottom: none;
}
.post-item a {
text-decoration: none;
color: inherit;
display: block;
}
.post-item h2 {
margin: 0 0 0.5rem 0;
color: #333;
transition: color 0.2s;
}
.post-item:hover h2 {
color: #666;
}
.post-item p {
margin: 0 0 0.5rem 0;
color: #666;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.post-meta time {
font-size: 0.9rem;
color: #999;
}
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
color: #666;
}
</style>
Create src/pages/blog/[...slug].astro:
---
import { type CollectionEntry, getCollection } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await post.render();
---
<BlogPost {...post.data}>
<Content />
</BlogPost>
Create First Post
blog new-post "Hello World"
Edit the generated post in src/content/blog/hello-world.md.
Development
Start development server:
blog dev
Build for production:
blog build
GitHub Pages Deployment
1. Create GitHub Repo
- Create a new repository on GitHub (e.g., my-blog)
- Don’t initialize with README, .gitignore, or license
2. Initialize Git and Push
git init
git add .
git commit -m "Initial blog setup with Nix flake"
git branch -M main
git remote add origin https://github.com/yourusername/my-blog.git
git push -u origin main
3. Create GitHub Actions Workflow
Create .github/workflows/deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
4. Enable GitHub Pages
- Go to your repository on GitHub
- Click Settings → Pages
- Under Source, select GitHub Actions
- Specify your custom domain; add A records
5. Deploy
git add .
git commit -m "Add GitHub Actions deployment workflow"
git push
Testing and Going Live
1. Monitor Deployment
- Go to the Actions tab in your GitHub repository
- Watch the deployment workflow complete (usually 2-3 minutes)
- Check for any errors in the build or deploy steps
2. Access Your Live Blog
Visit your custom domain URL
3. Verify everything works
- Homepage loads correctly
- Blog index page shows your posts
- Individual blog posts are accessible
- Navigation works between all pages
- Styling is applied correctly
Key Features
- Nix flake: Reproducible development environment
- direnv: Automatic environment loading
- Custom CLI:
blogcommand for common tasks - Astro: Modern static site generator
- TypeScript: Type safety
- MDX support: Enhanced markdown
- Sitemap generation: SEO optimization
Next Steps
You’ve likely noticed that the styles are quite different from what you’re seeing on my site, and that stylesheets are duplicated across different pages in this tutorial I’ll be using Tailwind CSS v4 for a more modern look-and-feel, with Claude doing most of the heavy lifting. Stay tuned for Part 2!