DEV Community

Cover image for How to Create Your Own Personal Blog with Astro
Thomas Bnt
Thomas Bnt Subscriber

Posted on • Originally published at thomasbnt.dev

How to Create Your Own Personal Blog with Astro

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.

An example of a post on my website under Astro

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. .astro components 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 .md file, 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// astro.config.ts
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});
Enter fullscreen mode Exit fullscreen mode

And a CSS entry point that imports Tailwind:

/* src/styles/global.css */
@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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.astro that receives a collection entry, displays the title and dates, and renders the Markdown via render(post):
---
import { render } from "astro:content";
const { post } = Astro.props;
const { Content } = await render(post);
---

<article>
  <Content />
</article>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// 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];
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// wrangler.jsonc
{
  "name": "my-blog",
  "compatibility_date": "2026-01-01",
  "assets": {
    "directory": "./dist"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm run build
npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

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.

Learn More

Top comments (0)