DUONGHOANGNAM

Search Articles

Find AI, technology, and programming guides

Press Esc to closeFull Search Page →
Tutorials

How to Build an Astro Blog with Decap CMS and Cloudflare Pages

Learn how to build an Astro blog with Decap CMS and deploy it to Cloudflare Pages. A step-by-step guide to GitHub integration, content management, and SEO.

By Duong Hoang Nam
How to Build an Astro Blog with Decap CMS and Cloudflare Pages

A blog is easy to launch. Building one that is fast, maintainable, searchable, and comfortable to publish on is a more interesting engineering challenge.

A traditional content management system may introduce a database, server maintenance, and additional infrastructure. A static site takes a different approach: write content in Markdown, generate HTML during a build, and deliver those pages through a CDN.

In this tutorial, we’ll combine Astro, Decap CMS, GitHub, and Cloudflare Pages to create a practical publishing workflow. We’ll also cover the details that are easy to overlook: content validation, GitHub authentication, draft handling, search, SEO, and image delivery.

This approach is informed by the stack behind DuongHoangNam.com. The examples below are a simplified starter implementation, not a copy of every component used on this website.

What You’ll Build

By the end, you’ll understand how to assemble a system in which:

  1. Astro generates blog pages from Markdown files.
  2. Decap CMS provides a browser-based editor at /admin/.
  3. GitHub stores content and records editorial changes.
  4. Cloudflare Pages builds and serves the published website.
  5. An optional Pagefind index makes published articles searchable.

The important distinction is that Decap CMS edits files; Astro renders them. You don’t need a traditional content database for this workflow.

The technology stack

Tool Responsibility
Astro Static page generation and content rendering
Markdown + content collections Article storage, metadata, and validation
Decap CMS Editorial interface for writing and managing articles
GitHub Version control and publishing history
Cloudflare Pages Automated builds, deployments, and static hosting
Pagefind (optional) Search index generated from built HTML

Prerequisites

Before starting, prepare:

  • A recent Node.js version supported by your installed Astro release, plus npm.
  • A GitHub account and a repository you can write to.
  • A Cloudflare account if you plan to deploy with Cloudflare Pages.
  • Basic familiarity with the terminal and editing project files.
  • A domain name if you want a custom URL; the initial deployment can use a Pages subdomain.

Important: GitHub login for Decap CMS requires an authentication solution. On a Cloudflare Pages setup, this typically means an OAuth proxy, such as a properly configured Cloudflare Worker, or a supported hosted authentication option. Creating the admin page alone does not enable login.

Step 1: Create an Astro Project

Open a terminal and run:

npm create astro@latest

Follow the setup prompts and choose an appropriate starter. Then enter your project directory and launch the development server:

cd your-astro-project
npm install
npm run dev

The terminal will display a local URL. Open it to confirm Astro runs successfully before you start adding CMS functionality.

For this guide, we’ll use static output. That means the website is built into HTML and assets before it is deployed, rather than generating each article on demand.

Step 2: Define a Blog Content Collection

Astro content collections give Markdown files a consistent structure and validate their metadata during development and builds.

Create src/content.config.ts:

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

const blog = defineCollection({
  loader: glob({
    pattern: '**/*.md',
    base: './src/content/blog',
  }),
  schema: z.object({
    title: z.string().min(1),
    description: z.string().min(1),
    date: z.coerce.date(),
    author: z.string().default('Site Author'),
    category: z.enum([
      'ai-tools',
      'tutorials',
      'automation',
      'technology',
    ]),
    image: z.string().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

The glob() loader reads Markdown files from the directory, while the schema makes sure each article contains the fields your site expects.

Next, create src/content/blog/hello-astro.md:

---
title: "Hello Astro"
description: "My first article in an Astro-powered blog."
date: 2026-10-20
author: "Site Author"
category: tutorials
draft: false
---

## Why Astro?

Astro makes it straightforward to publish content-focused pages.

## What Comes Next?

We'll connect this Markdown file to a reusable article template.

The filename will become the article’s entry ID in this setup. For example, hello-astro.md can map to /blog/hello-astro/.

Keep URLs stable after publication. Renaming a Markdown file changes the route unless you create an appropriate redirect.

Step 3: Generate Article Pages

Create a dynamic route at src/pages/blog/[id].astro:

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

export async function getStaticPaths() {
  const posts = await getCollection(
    'blog',
    ({ data }) => !data.draft
  );

  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
const site = Astro.site ?? new URL('https://example.com');
const canonical = new URL(`/blog/${post.id}/`, site);
---

<!doctype html>
<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} />
    <link rel="canonical" href={canonical.toString()} />
  </head>
  <body>
    <main>
      <a href="/blog/">All articles</a>
      <header>
        <h1>{post.data.title}</h1>
        <p>{post.data.description}</p>
        <time datetime={post.data.date.toISOString()}>
          {post.data.date.toLocaleDateString('en-US', {
            timeZone: 'UTC',
          })}
        </time>
      </header>
      <article data-pagefind-body>
        <Content />
      </article>
    </main>
  </body>
</html>

This example intentionally focuses on rendering and metadata. In a production site, you would normally move the shared HTML structure into a reusable layout and add a proper design system.

Notice the draft filter inside getCollection(). A file with draft: true will not receive a published article route from this template. That is separate from Decap CMS’s editorial Draft status, which controls whether a change has been merged into the publishing branch.

For a simple blog listing, create src/pages/blog/index.astro:

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

const posts = (await getCollection(
  'blog',
  ({ data }) => !data.draft
)).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---

<h1>Blog</h1>
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.id}/`}>{post.data.title}</a>
      <p>{post.data.description}</p>
    </li>
  ))}
</ul>

At this point, you have a working file-based publishing foundation.

Step 4: Add the Decap CMS Admin Interface

Create these two files:

public/
  admin/
    index.html
    config.yml

Add the admin entry point in public/admin/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>Content Manager</title>
  </head>
  <body>
    <script src="https://cdn.jsdelivr.net/npm/decap-cms@^3.0.0/dist/decap-cms.js"></script>
  </body>
</html>

Next, configure public/admin/config.yml:

backend:
  name: github
  repo: your-account/your-repository
  branch: main
  base_url: https://auth.example.com
  auth_endpoint: /auth

media_folder: public/images/uploads
public_folder: /images/uploads
publish_mode: editorial_workflow

collections:
  - name: blog
    label: Blog Articles
    folder: src/content/blog
    create: true
    extension: md
    format: frontmatter
    slug: "{{slug}}"
    identifier_field: title
    fields:
      - { label: Title, name: title, widget: string }
      - { label: Description, name: description, widget: text }
      - label: Publish Date
        name: date
        widget: datetime
        format: YYYY-MM-DD
        date_format: YYYY-MM-DD
        time_format: false
      - label: Author
        name: author
        widget: string
        default: Site Author
      - label: Category
        name: category
        widget: select
        options:
          - { label: AI Tools, value: ai-tools }
          - { label: Tutorials, value: tutorials }
          - { label: Automation, value: automation }
          - { label: Technology, value: technology }
      - { label: Featured Image, name: image, widget: image, required: false }
      - { label: Draft, name: draft, widget: boolean, default: false }
      - { label: Article Content, name: body, widget: markdown }

Replace your-account/your-repository with your actual GitHub repository and https://auth.example.com with the address of your working OAuth service. The example URL is a placeholder, not a functional login endpoint.

The media_folder and public_folder values serve different purposes: the first specifies where uploaded files are stored in the repository; the second provides the URL path used by the website.

For an existing site with managed category files, you can replace the simple Category select widget with a Decap relation widget. Keep the stored category value consistent with your Astro schema.

Step 5: Configure GitHub Authentication Safely

Unlike editing a local Markdown file, signing in through Decap CMS requires GitHub authorization.

For a self-managed OAuth flow, the essential pieces are:

  1. Create a GitHub OAuth application for your CMS.
  2. Deploy an authentication service that handles GitHub authorization and its callback.
  3. Store the GitHub client secret in the authentication service’s secret storage—not in your Astro repository or browser-side JavaScript.
  4. Set the Decap base_url and auth_endpoint to the working service.
  5. Sign in through /admin/ with an account authorized to modify the repository.

The OAuth service itself is a separate deployment and is not implemented by the YAML example above. Use the Decap CMS GitHub backend documentation and backend/OAuth guidance to select and configure an appropriate implementation.

Never commit OAuth client secrets, GitHub personal access tokens, or private credentials into a public website repository.

Step 6: Understand the Editorial Workflow

With publish_mode: editorial_workflow, Decap CMS separates editing from publication.

A typical lifecycle is:

Draft → In Review → Ready → Publish

Saving an editorial draft stores changes on a working branch. Publishing merges the approved changes into the configured publication branch, where your deployment system can build the updated site.

There are two different meanings of “draft” here:

  • Editorial Draft: The change has not yet been published through the CMS workflow.
  • Markdown draft: true: Your Astro route generator can deliberately exclude the article from public pages.

Confusing the two can lead to test articles appearing publicly or approved content never reaching the production website.

Step 7: Deploy to Cloudflare Pages

Push your project to GitHub, then connect that repository to Cloudflare Pages using its Git integration.

For an Astro static site, the relevant build settings are:

Setting Value
Framework preset Astro
Build command npm run build
Build output directory dist
Production branch main (or your chosen publication branch)

Make sure Cloudflare uses a Node.js version compatible with your Astro release. Pin a supported version in your project or build settings when necessary rather than relying on an older environment default.

Before merging changes into main, use a development branch and verify the Cloudflare Preview deployment. This is especially useful when changing the CMS configuration or article templates.

A successful Astro build does not automatically prove that every Decap CMS widget is configured correctly. Test both the website and the editorial interface.

Step 8: Add Essential SEO Foundations

A content-focused website needs more than a fast build. Each article should have meaningful metadata and a crawlable URL.

Configure canonical URLs and a sitemap

Install Astro’s sitemap integration:

npx astro add sitemap

Then ensure your astro.config.mjs declares the deployed site URL:

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

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

Replace the example domain with your real domain. Astro can then generate a sitemap for the static routes it builds.

A basic public/robots.txt can reference that sitemap:

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

A sitemap helps discovery, but it does not guarantee indexing or rankings. The content itself still needs to solve a real problem for readers.

Write metadata for people, not just crawlers

For each article:

  • Use one descriptive H1, normally the article title.
  • Write a concise, specific meta description.
  • Use H2 and H3 headings to organize the explanation.
  • Add relevant internal links to related content.
  • Provide meaningful image alternative text.
  • Update examples when tools or APIs change.

Avoid generating many nearly identical articles simply to target small keyword variations. A smaller library of genuinely useful tutorials is a stronger foundation.

Step 9: Add Search with Pagefind

Pagefind generates a search index from your built HTML, so it works well with a static Astro site.

Install it as a development dependency:

npm install --save-dev pagefind

Then update the build script in package.json:

{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

The command runs Astro first, then creates the Pagefind index in the generated dist directory.

In the article template above, data-pagefind-body marks the part of the page you want indexed. You can add a Pagefind search interface afterward using its documented UI components.

Development tip: A search interface may have no results while running the Astro development server because the production search index is created after a build. Test it against a completed build or preview.

Step 10: Optimize Images Without Breaking CMS Uploads

Images deserve special attention on static sites.

With the configuration shown earlier, Decap CMS stores uploads in public/images/uploads/. Astro serves files in public/ as-is; simply adding an <img> tag does not automatically produce smaller WebP variants.

You have two broad approaches:

  • Astro image assets: Import local images from src/ and use Astro’s image tooling when the project structure allows it.
  • Build-time CMS image optimization: Keep CMS-managed files in public/, then generate responsive assets during the build using a tool such as Sharp. Your components can use srcset and sizes to select the appropriate version.

For instance, a responsive image might use:

<img
  src="/images/optimized/cover-1200.webp"
  srcset="/images/optimized/cover-480.webp 480w,
          /images/optimized/cover-768.webp 768w,
          /images/optimized/cover-1200.webp 1200w"
  sizes="(max-width: 768px) 100vw, 768px"
  width="1200"
  height="675"
  alt="A developer workspace with a laptop and notes"
  loading="lazy"
  decoding="async"
/>

Those cover-*.webp paths are illustrative: your build pipeline must create the files before this markup can work. The width and height attributes do not compress the original file by themselves.

For an image near the top of an article that affects Largest Contentful Paint, consider prioritizing its load rather than applying lazy loading indiscriminately.

Common Problems and How to Diagnose Them

The CMS loads, but GitHub login fails

Check the OAuth callback URL, authentication proxy deployment, repository permissions, and whether the client secret is configured securely. A visible /admin/ page only proves that the CMS JavaScript loaded.

The Astro build works, but CMS collections disappear

Astro does not validate the completeness of public/admin/config.yml. Confirm the YAML parses correctly and still contains every required collection before deploying a CMS change.

The article image appears broken

Compare the frontmatter image path with the real uploaded filename, including capitalization and extensions. A file named cover.png.png will not load from /images/uploads/cover.png.

Check whether the CMS stores the category’s display name or its URL slug. They are not interchangeable. Avoid silently changing established slugs when editing category titles.

A published change overwrites work in another branch

The CMS may publish directly to main while development continues elsewhere. Fetch and merge the latest publication branch before opening a feature pull request, and resolve content conflicts deliberately.

Search doesn’t find newly written content

Confirm the article was published, the build included its route, and Pagefind ran after the static files were generated. Also check that your page’s searchable content isn’t accidentally excluded.

Final Pre-Launch Checklist

Before calling the site ready, verify:

  • Each published article loads at its expected URL.
  • Draft articles are excluded from public routes where intended.
  • The CMS login and publishing workflow both work.
  • Featured images return successfully and have appropriate alt text.
  • Canonical URLs and the sitemap use the production domain.
  • The sitemap, search index, and internal links are up to date.
  • Mobile navigation and light/dark themes are readable.
  • No OAuth secrets or access tokens are stored in public code.
  • A fresh build succeeds in the hosting environment.

Final Thoughts

A reliable publishing system is not only about choosing a fast framework. It is about making content easy to write, changes safe to publish, and problems straightforward to diagnose.

Astro provides the static foundation. Decap CMS makes editing accessible. GitHub tracks the changes. Cloudflare Pages delivers the result. Tools such as Pagefind and Sharp can then improve the reader experience without turning the site into an unnecessarily complicated application.

Start with a small working implementation. Test the complete publishing lifecycle with a real article and image. Add more features only after the basic workflow is dependable.

If you’re exploring these subjects, browse the Tutorials and Technology sections, or read Welcome to DuongHoangNam.com to learn more about the direction of this website.

Official documentation and further reading

About the Author

Duong Hoang Nam

Software engineer exploring artificial intelligence, practical technology solutions, automation, and digital creativity.

Read My Story →

Related Articles