Personal Blog Recipe
This recipe details the architecture of an editorial personal blog. In the Standard philosophy, an article should feel like a page in a finely printed book rather than an unstyled document. This template provides chronological archiving, tag categorization, estimated reading time, and baseline-grid typography.
Instant Scaffolding
To scaffold this project instantly with @stnd/cli, run:
npx @stnd/cli new my-blog --template blog
cd my-blog
pnpm install
pnpm dev
File Structure
The blog feature is packaged as a single vertical slice inside modules/blog/. Markdown content resides directly in modules/blog/content/posts/:
my-blog/
astro.config.mjs # Standard integration + Cloudflare
package.json # Dependencies and scripts
tsconfig.json # Path aliases
modules/
blog/
index.module.js # Module manifest mapping list and post routes
helpers.js # Data fetching, sorting, and reading time helpers
routes/
index.astro # Chronological index of published posts
[...slug].astro # Article reader with book typography
content/
posts/
2026-09-01-welcome.md
2026-09-08-the-art-of-typography.md
2026-09-14-minimal-toolchains.md
Architectural Breakdown
1. Astro Configuration (astro.config.mjs)
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import standard from "@stnd/core";
export default defineConfig({
site: "https://myblog.example",
output: "server",
adapter: cloudflare(),
integrations: [
standard({
title: "Personal Journal",
}),
],
});
2. Module Manifest (modules/blog/index.module.js)
The manifest maps the archive view to / and individual article pages to /[...slug] (or /posts/[...slug]):
export default {
id: "blog",
name: "Blog",
description: "Personal publishing vertical slice.",
routes: [
{ path: "/", entrypoint: "./routes/index.astro" },
{ path: "/posts/[...slug]", entrypoint: "./routes/[...slug].astro" },
],
};
3. Data Helper (modules/blog/helpers.js)
Per Law 3 (“.astro holds no logic”), content loading, frontmatter parsing, reading time calculation, and chronological sorting are extracted to a pure JavaScript helper:
/**
* Approximate reading time in minutes based on 200 words per minute.
*/
export function calculateReadingTime(text = "") {
const words = text.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(words / 200));
}
/**
* Load and sort all blog posts by date descending.
*/
export function getAllPosts(rawGlob) {
return Object.entries(rawGlob)
.map(([filepath, post]) => {
const frontmatter = post.frontmatter || {};
const body = typeof post.rawContent === "function" ? post.rawContent() : "";
const filename = filepath.split("/").pop().replace(/\.md$/, "");
const slug = frontmatter.slug || filename;
const readingTime = calculateReadingTime(body);
return {
slug,
title: frontmatter.title || "Untitled",
date: frontmatter.date || "1970-01-01",
description: frontmatter.description || "",
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [],
readingTime,
Content: post.Content,
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
4. Blog Index View (modules/blog/routes/index.astro)
The index displays the chronological post list, metadata tags, and reading estimates:
---
export const prerender = true;
import { getAllPosts } from "../helpers.js";
const rawPosts = import.meta.glob("../content/posts/*.md", { eager: true });
const posts = getAllPosts(rawPosts);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Journal — Thoughts & Notes</title>
</head>
<body class="rhythm inky">
<div class="box max-w-2xl py-16">
<header class="mb-12 border-bottom pb-6">
<h1 class="text-3xl font-bold mb-2">Journal</h1>
<p class="opacity-70 text-sm spec">Personal essays, notes, and architectural studies.</p>
</header>
<main class="space-y-12">
{posts.map((post) => (
<article class="group">
<div class="flex items-baseline justify-between text-xs spec opacity-60 mb-1">
<time datetime={post.date}>{post.date}</time>
<span>{post.readingTime} min read</span>
</div>
<h2 class="text-xl font-bold mb-2">
<a href={`/posts/${post.slug}`} class="hover:underline">{post.title}</a>
</h2>
{post.description && <p class="opacity-80 text-sm mb-3">{post.description}</p>}
{post.tags.length > 0 && (
<div class="flex gap-2">
{post.tags.map((tag) => (
<span class="spec text-xs px-2 py-0.5 border rounded-none opacity-60">#{tag}</span>
))}
</div>
)}
</article>
))}
</main>
</div>
</body>
</html>
5. Article Reader Route (modules/blog/routes/[...slug].astro)
The single post view wraps the rendered Markdown content with book typography:
---
export const prerender = true;
import { getAllPosts } from "../helpers.js";
export async function getStaticPaths() {
const rawPosts = import.meta.glob("../content/posts/*.md", { eager: true });
const posts = getAllPosts(rawPosts);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = post;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{post.title} — Journal</title>
</head>
<body class="rhythm inky">
<article class="box prose max-w-prose py-16">
<nav class="mb-8">
<a href="/" class="spec text-xs opacity-60 hover:opacity-100">← Back to Journal</a>
</nav>
<header class="mb-10 border-bottom pb-6">
<div class="flex items-center justify-between text-xs spec opacity-60 mb-2">
<time datetime={post.date}>{post.date}</time>
<span>{post.readingTime} min read</span>
</div>
<h1 class="text-3xl font-bold tracking-tight mb-4">{post.title}</h1>
{post.description && <p class="text-lg opacity-80 leading-snug">{post.description}</p>}
</header>
<div class="content rhythm">
<Content />
</div>
</article>
</body>
</html>
Post Frontmatter Schema
Each article stored under modules/blog/content/posts/*.md follows this frontmatter specification:
---
title: The Art of Typographic Precision
date: 2026-09-08
description: Exploring how baseline grids and mathematical measures shape reader retention.
tags:
- design
- typography
---
Your markdown content begins here...