Building a Technical Blog with Astro + Cloudflare

astro cloudflare mdx tailwind devops
... views likes

Building a Technical Blog with Astro + Cloudflare

All tools used in this guide are free. Astro, Cloudflare Workers (free tier), and Durable Objects (free tier) allow you to build and deploy a fully functional blog at zero cost.

This blog is built using Astro 5 and Cloudflare’s edge computing technology. Here’s how to build a fast and scalable blog system from scratch.

Tech Stack

Frontend

Backend & Infrastructure

Why This Stack?

1. Performance

2. Scalability

3. Developer Experience

Interactive Features

One of the key features of this blog is the interactive like button with smooth animations. When users click the like button, they see:

Like Button Animation

The like system uses localStorage to track user interactions (up to 10 likes per article) and Durable Objects to persist the total count globally. The blog listing page uses a batch API to fetch stats for all posts in a single request, and bot detection prevents crawlers from inflating view counts. This creates a delightful user experience while maintaining simplicity and performance.

Project Structure

shusukedev-blog/
├── src/
│   ├── content/
│   │   ├── blog/              # Blog posts (MDX)
│   │   │   └── *.mdx
│   │   └── config.ts          # Content Collections schema
│   ├── layouts/
│   │   └── Layout.astro       # Common layout
│   ├── pages/
│   │   ├── index.astro        # Home page
│   │   ├── blog/
│   │   │   ├── index.astro    # Blog listing
│   │   │   └── [...slug].astro # Individual post pages
│   │   └── api/               # API endpoints
│   │       ├── views/[slug].ts
│   │       └── likes/[slug].ts
│   ├── components/
│   │   ├── ViewCount.astro    # View count display
│   │   └── LikeButton.astro   # Like button
│   ├── durable-objects/
│   │   └── ViewCounter.ts     # Durable Object class
│   └── styles/
│       └── global.css
├── astro.config.mjs
├── wrangler.jsonc             # Cloudflare configuration
├── package.json
└── tailwind.config.js

Setup Instructions

1. Create Project

Generate the initial project structure using Astro’s official CLI.

npm create astro@latest shusukedev-blog
cd shusukedev-blog

2. Install Dependencies

Add Cloudflare Workers deployment, MDX support, Tailwind CSS styling, type definitions, and build tools.

npm install @astrojs/cloudflare @astrojs/mdx @astrojs/sitemap
npm install @tailwindcss/vite tailwindcss
npm install -D @cloudflare/workers-types esbuild

Package roles:

3. Astro Configuration

Define Astro’s behavior including MDX, Sitemap, Tailwind CSS integrations, and Cloudflare Workers deployment settings.

astro.config.mjs:

import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import tailwindcss from "@tailwindcss/vite";
import cloudflare from "@astrojs/cloudflare";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://shusukedev.com",
  integrations: [mdx(), sitemap()],
  vite: {
    plugins: [tailwindcss()],
  },
  adapter: cloudflare(),
});

4. Define Content Collections Schema

Define type-safe schema for blog post frontmatter (metadata). This enables type checking and editor autocomplete when writing articles.

src/content/config.ts:

import { defineCollection, z } from "astro:content";

const blog = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

5. Implement Durable Objects

Implement backend logic to persist view counts and likes for each article. Durable Objects is Cloudflare’s globally distributed state management service.

src/durable-objects/ViewCounter.ts:

export interface Env {
  VIEW_COUNTER: DurableObjectNamespace;
}

interface CounterData {
  views: number;
  likes: number;
}

export class ViewCounter {
  state: DurableObjectState;
  env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    try {
      if (path === "/views" && request.method === "POST") {
        return await this.incrementViews();
      }

      if (path === "/likes" && request.method === "POST") {
        return await this.incrementLikes();
      }

      if (path === "/stats" && request.method === "GET") {
        return await this.getStats();
      }

      return new Response("Not Found", { status: 404 });
    } catch (error) {
      console.error("Durable Object fetch error:", error);
      return new Response("Internal Server Error", { status: 500 });
    }
  }

  private async incrementViews(): Promise<Response> {
    const data = await this.getData();
    data.views++;
    await this.state.storage.put("data", data);

    return new Response(JSON.stringify(data), {
      headers: { "Content-Type": "application/json" },
    });
  }

  private async incrementLikes(): Promise<Response> {
    const data = await this.getData();
    data.likes++;
    await this.state.storage.put("data", data);

    return new Response(JSON.stringify(data), {
      headers: { "Content-Type": "application/json" },
    });
  }

  private async getStats(): Promise<Response> {
    const data = await this.getData();

    return new Response(JSON.stringify(data), {
      headers: { "Content-Type": "application/json" },
    });
  }

  private async getData(): Promise<CounterData> {
    const stored = await this.state.storage.get<CounterData>("data");
    return stored || { views: 0, likes: 0 };
  }
}

6. Cloudflare Configuration

Define Cloudflare Workers deployment settings and Durable Objects bindings. This allows Workers to recognize the ViewCounter class and make it available via API.

wrangler.jsonc:

{
  "main": "dist/_worker.js/index.js",
  "name": "shusukedev-blog",
  "workers_dev": false,
  "compatibility_date": "2025-11-18",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "binding": "ASSETS",
    "directory": "./dist"
  },
  "observability": {
    "enabled": true
  },
  "durable_objects": {
    "bindings": [
      {
        "name": "VIEW_COUNTER",
        "class_name": "ViewCounter",
        "script_name": "shusukedev-blog"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["ViewCounter"]
    }
  ]
}

7. API Endpoints

Create API routes to communicate with Durable Objects. These endpoints handle view count increments and statistics retrieval for each blog post.

src/pages/api/views/[slug].ts:

import type { APIRoute } from "astro";

export const prerender = false;

export const POST: APIRoute = async ({ params, locals }) => {
  const { slug } = params;

  if (!slug) {
    return new Response(JSON.stringify({ error: "Slug is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    const id = locals.runtime.env.VIEW_COUNTER.idFromName(slug);
    const stub = locals.runtime.env.VIEW_COUNTER.get(id);
    const response = await stub.fetch("http://internal/views", {
      method: "POST",
    });
    const data = await response.json();

    return new Response(JSON.stringify(data), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Failed to increment views:", error);
    return new Response(
      JSON.stringify({ error: "Failed to increment views" }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
};

export const GET: APIRoute = async ({ params, locals }) => {
  const { slug } = params;

  if (!slug) {
    return new Response(JSON.stringify({ error: "Slug is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    const id = locals.runtime.env.VIEW_COUNTER.idFromName(slug);
    const stub = locals.runtime.env.VIEW_COUNTER.get(id);
    const response = await stub.fetch("http://internal/stats");
    const data = await response.json();

    return new Response(JSON.stringify(data), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Failed to get stats:", error);
    return new Response(JSON.stringify({ error: "Failed to get stats" }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
};

8. Create Blog Posts

Write blog content using MDX format. MDX allows you to use JSX components within Markdown, enabling interactive elements and rich content.

src/content/blog/example.mdx:

---
title: "Post Title"
description: "Post description"
pubDate: 2025-11-20
tags: ["astro", "cloudflare"]
draft: false
---

# Heading

Write your content here.

## Code Blocks

\`\`\`typescript
const greeting = "Hello, World!";
console.log(greeting);
\`\`\`

## Embed Components

import CustomComponent from "../../components/CustomComponent.astro";

<CustomComponent />

Deployment

Local Development

npm run dev

Build

npm run build

Deploy to Cloudflare Workers

npx wrangler deploy

Or use GitHub Actions for automatic deployment:

  1. Set up Cloudflare API token in GitHub Secrets
  2. Configure GitHub Actions workflow
  3. Push to main branch triggers automatic deployment

Design Refinement with Claude Code

Building a blog isn’t just about functionality—design matters. I used Claude Code as my design partner throughout the development process.

This approach was inspired by Anthropic’s article on improving frontend design through skills. The article highlights a key challenge: LLMs tend to generate generic, “safe” designs. By leveraging Claude Code’s design expertise, I avoided the typical “AI-generated” aesthetic.

Dark Mode

Claude Code helped implement dark mode that feels intentional, not just an inverted color scheme:

Result

The final design avoids common AI-generated patterns while maintaining clean, professional aesthetics. It feels purposeful rather than generic—exactly what the Claude Skills blog post advocates for.

Summary

This stack provides:

Questions? Reach out on X @shusukedev.

Have thoughts on this article?

Share your feedback or questions by quote posting on X.

Send a comment
← Back to all posts