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
- Astro 5 - Static site generator with Content Collections support
- MDX - Markdown + React components
- Tailwind CSS 4 - Utility-first CSS framework
Backend & Infrastructure
- Cloudflare Workers - Serverless runtime at the edge
- Cloudflare Durable Objects - Globally distributed state management
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
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:
-
@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(),
});
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:
- Set up Cloudflare API token in GitHub Secrets
- Configure GitHub Actions workflow
- 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
localStorageandprefers-color-scheme -
Subtle borders (
gray-200→gray-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)