Tutorials

Build and Deploy a Markdown Blog with Astro

From npm create to a live URL: build a fast, SEO-ready blog with Astro's content collections, zero client JavaScript by default, and free hosting.

Marcus Webb

Senior Writer

Elena Ruiz

Fact Checked

Cheerful rocket built from paper documents launching from an open laptop with a light trail

There are a hundred ways to build a blog, and most of them are too much. A blog is fundamentally text, rendered fast — it doesn’t need a client-side framework, a database, or a hydration strategy. It needs Markdown in, HTML out, and good SEO plumbing.

That’s Astro’s home turf. Astro is a web framework that renders everything to static HTML at build time and ships zero JavaScript by default — you opt individual components into interactivity, not out of it. The result: perfect-by-default performance and a delightful authoring workflow. (This very site runs on Astro using exactly the techniques below, so you’re reading the output of the tutorial right now.)

Let’s build a complete blog: typed content, article pages, RSS, sitemap, deploy.

The whole architecture on one card: Markdown in, static HTML out, JavaScript only where you ask for it.

Scaffold

You’ll need Node 20+. Then:

npm create astro@latest my-blog
# choose: "Empty" template, TypeScript: yes
cd my-blog
npm run dev

http://localhost:4321 now serves your empty site with hot reload. The project anatomy that matters:

src/
  pages/        ← every file here becomes a route
  components/   ← reusable .astro components
  layouts/      ← page shells
  content/      ← your Markdown lives here
public/         ← static files, copied verbatim

.astro files are HTML with a typed frontmatter script between --- fences — think “server-side component”: the script runs at build time, the markup below it is the output.

Typed content with collections

Astro’s killer feature for blogs: content collections — Markdown with an enforced schema. Define it in src/content.config.ts:

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
  schema: z.object({
    title: z.string(),
    description: z.string().max(160),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { posts };

Now a post is just a file, src/content/posts/hello-world.md:

---
title: "Hello, World"
description: "The obligatory first post — and why this blog exists."
pubDate: 2026-06-01
tags: [meta]
---

Welcome! This post is **plain Markdown** with typed frontmatter.

Forget the description, typo a date, and the build fails with a precise error — at publish time, not in production three weeks later when you notice the broken meta tags. Typed content is the single biggest quality-of-life difference between Astro blogging and older static generators.

The article page: one file, all posts

Create src/pages/posts/[slug].astro — brackets mean a dynamic route, and getStaticPaths tells Astro every value it should build:

---
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('posts', ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
const date = post.data.pubDate.toLocaleDateString('en-US', { dateStyle: 'long' });
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{post.data.title}</title>
    <meta name="description" content={post.data.description} />
  </head>
  <body>
    <article>
      <h1>{post.data.title}</h1>
      <time datetime={post.data.pubDate.toISOString()}>{date}</time>
      <Content />
    </article>
  </body>
</html>

At build, this one file becomes /posts/hello-world/, /posts/second-post/, and every future article — each a pure HTML page with real meta tags, no JavaScript required to render a single word. Search engines see exactly what readers see, with none of the client-side-rendering SEO anxieties.

The index page is the same idea in reverse — src/pages/index.astro lists the collection:

---
import { getCollection } from 'astro:content';
const posts = (await getCollection('posts', ({ data }) => !data.draft))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/posts/${post.id}/`}>{post.data.title}</a>
      <p>{post.data.description}</p>
    </li>
  ))}
</ul>

SEO plumbing in ten minutes

A blog nobody can find is a diary. Three integrations, then it’s done forever.

Set your site URL in astro.config.mjs (everything else derives from it):

import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://yourdomain.com',
  integrations: [sitemap()],
});

Sitemap: npx astro add sitemap — done; it generates on every build. RSS: install @astrojs/rss and add src/pages/rss.xml.ts:

import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
  const posts = await getCollection('posts', ({ data }) => !data.draft);
  return rss({
    title: 'My Blog',
    description: 'Posts about things I build',
    site: context.site,
    items: posts.map((p) => ({
      title: p.data.title,
      description: p.data.description,
      pubDate: p.data.pubDate,
      link: `/posts/${p.id}/`,
    })),
  });
}

Add a robots.txt in public/ pointing at your sitemap, canonical URLs and Open Graph tags in a shared layout head, and your SEO fundamentals are genuinely covered. Because output is static HTML with correct semantics, you start from a Lighthouse score most JavaScript-first stacks have to fight for — the mechanics of why are in our Core Web Vitals guide.

When you do want JavaScript: islands

Eventually something needs interactivity — a theme toggle, search, a comment widget. Astro’s model is islands: the page stays static, and individual components hydrate independently:

<ThemeToggle client:load />          <!-- hydrate immediately -->
<SearchBox client:idle />            <!-- hydrate when browser is idle -->
<Comments client:visible />          <!-- hydrate when scrolled into view -->

You can write islands as plain <script> tags in Astro components, or bring React/Svelte/Vue components just for those spots. The discipline the framework enforces — every byte of client JS is an explicit decision — is exactly the discipline that keeps blogs fast for years. (It’s the same “server by default, client where needed” philosophy we explored in Server vs Client Components, implemented even more radically.)

Deploy: push, connect, done

npm run build produces a dist/ folder of plain files — deployable literally anywhere that serves static files. The zero-effort path:

  1. Push the project to GitHub.
  2. Connect the repo on your static host of choice — Netlify, Vercel and Cloudflare Pages all auto-detect Astro, build on every push and serve from a global CDN, free at hobby scale.
  3. Add your custom domain in the host’s dashboard.

Each push to main now deploys automatically. Which host? For a static blog they’re near-interchangeable; our Vercel vs Netlify vs Cloudflare Pages comparison breaks down the free-tier fine print if you want to choose deliberately.

From here, the upgrade path is smooth and optional: MDX for components inside posts, view transitions for app-like navigation, image optimization with the built-in <Image> component, content from a CMS via loaders — all additive, none required on day one.

Frequently Asked Questions

Can I use React components in an Astro site? Yes — that’s a core feature. Install the React integration (npx astro add react) and drop React components in as islands with a client:* directive when they need interactivity, or with none at all to render them to static HTML. The same goes for Vue, Svelte, Solid and Preact, even mixed in one project. It makes Astro a gentle migration path: reuse the component library you already have while the pages themselves go static.

How do I add a CMS so non-developers can write posts? Markdown-in-git is a CMS for developer blogs, but for editors who won’t touch a repo you have two clean options: a git-based CMS (Decap, Tina) that gives a friendly editing UI which commits Markdown behind the scenes, or a hosted headless CMS (Sanity, Contentful, Storyblok) pulled in through Astro’s content loaders at build time. The site stays static either way; only the writing experience changes.

What about comments and search on a static site? Both have static-friendly answers. Comments: Giscus (backed by GitHub Discussions) is free, ad-free and loads as a lazy island. Search: for a blog under a few hundred posts, generate a JSON index at build time and filter it client-side with a few kilobytes of JavaScript — no server, instant results. Pagefind is the heavier-duty option that indexes your built HTML automatically.

Does Astro scale to big sites? Content sites with tens of thousands of pages build comfortably (the content layer caches between builds), and because output is static, serving scales infinitely on any CDN. The honest limit is build frequency: if you need content updated every few seconds, you want Astro’s server-rendering mode or a different architecture, not thousands of rebuilds a day.

Final Thoughts

A complete, fast, SEO-ready blog in an afternoon: typed Markdown in collections, one dynamic route rendering every article, sitemap and RSS as one-liners, free global hosting on push. No database to maintain, no framework runtime to update, no hydration bugs at 2 a.m. — because there’s nothing running in production except files.

The deeper lesson is architectural: match the tool to the content’s nature. Blogs are read, not operated; static-first isn’t a compromise for them, it’s the correct default — and it’s why this stack (and this site) will still build unchanged years from now. Write your first three posts before you touch the CSS. The words are the product; Astro just makes sure nothing gets in their way.