DEV Community

shusukeO
shusukeO

Posted on • Originally published at shusukedev.com

Building a Technical Blog with Astro + Cloudflare

Building a Technical Blog with Astro + Cloudflare

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

  • Astro's partial hydration loads JavaScript only where needed
  • Cloudflare's global edge network provides low-latency access worldwide
  • Static generation ensures fast initial page loads

2. Scalability

  • Durable Objects enable serverless state management (view counts, likes)
  • Pay-as-you-go pricing scales from small to large

3. Developer Experience

  • MDX allows embedding components in articles
  • Astro's Content Collections provide type-safe content management
  • Tailwind CSS 4's new CSS variable-based design system

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:

  • Heart bounce animation - The heart icon scales up and bounces (0.6s)
  • Count-up animation - The like count fades in from below (0.4s)
  • Button feedback - The button briefly shrinks on click (0.15s)
  • Color transition - The heart changes to red with a smooth transition

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

Setup Instructions

1. Create Project

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

npm create astro@latest shusukedev-blog
cd shusukedev-blog
Enter fullscreen mode Exit fullscreen mode

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

Package roles:

  • @astrojs/cloudflare - Adapter for Cloudflare Workers deployment
  • @astrojs/mdx - MDX (Markdown + JSX) support
  • @astrojs/sitemap - Automatic sitemap.xml generation for SEO
  • @tailwindcss/vite - Tailwind CSS Vite plugin
  • @cloudflare/workers-types - TypeScript type definitions (dev only)
  • esbuild - Transpile Durable Objects (dev only)

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

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

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

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

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

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

Deployment

Local Development

npm run dev
Enter fullscreen mode Exit fullscreen mode

Build

npm run build
Enter fullscreen mode Exit fullscreen mode

Deploy to Cloudflare Workers

npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

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:

  • Prevented FOUC with an inline script checking localStorage and prefers-color-scheme
  • Subtle borders (gray-200gray-800) that define sections without overwhelming the design
  • Adjusted code block backgrounds for better syntax highlighting in dark mode

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:

  • Fast loading speeds - Static delivery at the edge + partial hydration
  • Real-time state management - View counts and likes via Durable Objects
  • Excellent developer experience - Type-safe Content Collections + MDX
  • Scalable architecture - Serverless with automatic scaling

Questions? Reach out on X @shusukedev.


Originally published at shusukedev.com

Top comments (0)