You want a personal blog without spending three months on it or paying for a CMS every month.
This site you’re reading right now is exactly that: an Astro blog.
No database, no admin panel to maintain:
- Markdown files
- Content Collections
- A bit of Tailwind
- And a static deployment on Cloudflare Workers.
Here’s how to set it up, using the same building blocks as the ones used here.
A personal blog is a project you should be able to maintain on your own, for as long as you want. No heavy external dependencies, no CMS that disappears overnight, no server to run. Just a Git repository, Markdown files, and static hosting.
Why Astro for a blog
Astro itself explains its philosophy in “Why Astro”: a framework designed for content-focused sites, server-side by default. In practical terms, this means:
-
Simplicity: no state management, no JavaScript framework to learn just to write an article.
.astrocomponents and Markdown that’s it. -
Speed: Astro renders static HTML by default; no JavaScript is sent to the client unless you explicitly request it (
client:load, etc.). For a blog, this is exactly what you need: content that’s easy to read, not a SPA. - Fast deployment: static build, no servers to run, deploys to any CDN in seconds.
-
Markdown content: each post is a
.mdfile, versioned in Git, editable in any text editor. - Easy to maintain: Content collections provide data typing for front matter using Zod, which is validated directly during the build a front matter error breaks the build rather than silently crashing in production.
Create the project
npm create astro@latest -- --template minimal
cd mon-blog
npm install
Once Tailwind and the content collections have been added (next steps), the directory structure of a minimal Astro blog looks like this:
my-blog/
├── astro.config.ts
├── package.json
├── tsconfig.json
└── src/
├── content.config.ts # article structure (Zod)
├── styles/
│ └── global.css # import Tailwind
├── data/
│ └── blog/
│ ├── premier-article/
│ │ ├── index.md
│ │ └── banner.webp
│ └── deuxieme-article/
│ └── index.md
├── layouts/
│ ├── Layout.astro # <head>, meta, theme
│ └── PostDetails.astro # The rendering of an article
├── components/
│ └── Header.astro
└── pages/
├── index.astro
└── blog/
├── index.astro # List of Articles
└── [...slug]/
└── index.astro # article page (dynamic route)
A deliberately simplified structure to get started: in a real project, you’ll quickly add a pagination route ([...page].astro) and quite a few other components as you go.
Add Tailwind (v4, via the Vite plugin, no need to manage a tailwind.config.js file):
npm install tailwindcss @tailwindcss/vite
// astro.config.ts
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
And a CSS entry point that imports Tailwind:
/* src/styles/global.css */
@import "tailwindcss";
Articles in Content Collections
Each article is stored in its own folder under src/data/blog/<slug>/, along with an index.md file and its images (banner.webp, screenshots, etc.).
This keeps each article self-contained: there’s no need to search through a shared public/images folder to find an asset.
The schema is declared once, in src/content.config.ts:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/[^_]*.md", base: "./src/data/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDatetime: z.date(),
modDatetime: z.date().optional(),
draft: z.boolean().default(true),
tags: z.array(z.string()).default(["notes"]),
ogImage: image().or(z.string()).optional(),
}),
});
export const collections = { blog };
So an article is just that:
---
title: "My First Article"
description: "A short tagline for lists and SEO."
pubDatetime: 2026-08-10
draft: false
tags:
- Astro
---
Markdown content here.
If draft is set to true or pubDatetime is in the future, the article should not appear in the listings:
This is a filter you must apply yourself when calling getCollection; Astro does not do this for you:
import { getCollection } from "astro:content";
const now = Date.now();
const posts = await getCollection("blog", ({ data }) => {
const isPublished = now > data.pubDatetime.getTime();
return !data.draft && isPublished;
});
Layouts: One for the list, one for the post
Two layouts are enough for the vast majority of personal blogs:
- A base layout (
Layout.astro) that sets the<head>, the OpenGraph meta tags, and the light/dark theme. - A
PostDetails.astrothat receives a collection entry, displays the title and dates, and renders the Markdown viarender(post):
---
import { render } from "astro:content";
const { post } = Astro.props;
const { Content } = await render(post);
---
<article>
<Content />
</article>
All typographic styling (headings, quotes, code) is handled by a Tailwind class like prose applied to this <article>,
not by hand-written CSS on a per-element basis.
Lightweight i18n: a dictionary, not a router
There’s no need for Astro’s native i18n routing if the site has only one content language and just interface labels to translate.
A simple dictionary is all you need:
// src/i18n/ui.ts
export const defaultLang = "fr";
export const ui = {
fr: {
"nav.home": "Accueil",
"footer.rights": "Tous droits réservés",
},
en: {
"nav.home": "Home",
"footer.rights": "All rights reserved",
},
} as const;
// src/i18n/utils.ts
import { ui, defaultLang } from "./ui";
export function useTranslations(lang: keyof typeof ui) {
return function t(key: keyof (typeof ui)[typeof defaultLang]) {
return ui[lang][key] ?? ui[defaultLang][key];
};
}
In a component: const t = useTranslations(lang); t(“nav.home”).
Zero external dependencies, zero JSON translation files to load dynamically: everything is typed and validated during the build by TypeScript.
For the articles themselves, rather than having duplicate content in two languages, an optional englishUrl field in the front matter is enough to point to an English version published elsewhere (dev.to, for example), without having to maintain a complete translation that’s synchronized with every change.
Deploying to Cloudflare Workers
Astro in static mode generates a dist/ folder ready for hosting.
Cloudflare Workers can serve these static assets directly (no need for Pages) via Wrangler:
npm install -D wrangler
// wrangler.jsonc
{
"name": "my-blog",
"compatibility_date": "2026-01-01",
"assets": {
"directory": "./dist"
}
}
npm run build
npx wrangler deploy
Each wrangler deploy command pushes assets to the Cloudflare edge.
For automated CI/CD (push = deployment), connect your Git repo in the Cloudflare Workers dashboard or add a wrangler deploy step to your pipeline (GitHub Actions, for example).
No servers to manage, no cold starts for static content.
Cloudflare’s CDN serves files directly from the edge, and you can get right back to writing.
Don’t want to start from scratch?
My website is actually based on an existing theme: AstroPaper, a minimalist, responsive, accessible, and SEO-friendly blog theme, which I then customized to my liking (Tailwind v4, lightweight i18n, TOC, etc.).
No need to build everything from scratch: the official Astro theme marketplace lists dozens of ready-to-use templates (blogs, portfolios, documentation, e-commerce) that you can clone and then customize.
Conclusion
| Brick | Role |
|---|---|
| Astro | Generates static HTML; no JS by default |
| Content collections | Markdown articles, validated by a Zod schema |
| Tailwind v4 | Styling, with no configuration files to maintain |
| i18n dictionary | Translated interface labels, without heavy routing |
| Cloudflare Workers | Static hosting, one-command deployment |
A personal blog with Astro has very few moving parts: Markdown files organized into folders, a Zod schema that keeps the front matter clean, Tailwind for styling, and a static site generator that pushes everything to a CDN. No operations to manage, nothing to run continuously, just write, commit, push.

Top comments (0)