Building a Serverless Blog with Astro, Cloudflare, and Zero Monthly Cost
The Goal
Build a technical blog that:
- Loads instantly (static where possible)
- Has a custom admin panel (no external CMS)
- Authors content in Markdown (the only sane choice)
- Supports paste-to-upload images with auto WebP compression
- Renders Mermaid diagrams for architecture docs
- Embeds YouTube videos for demos
- Costs nothing to run
- Deploys in seconds
TLDR;
Astro lets you choose rendering mode per-route:
/blog/*→ Static (SSG) — HTML pre-built at deploy time, served from CDN/admin/*→ Dynamic (SSR) — rendered on each request by Cloudflare Workers
The admin panel is just Astro pages without the prerender = true flag. No third-party CMS like Contentful or WordPress.
When you visit /admin/posts:
- Cloudflare Worker intercepts the request
- Middleware checks Basic Auth
- Worker queries D1 (SQLite at the edge)
- Astro renders the page server-side
- HTML returned to browser
So readers get instant static pages, while authors get a full dynamic CMS — same repo, same deploy, zero external services.
The "static" part is just the public-facing blog. The admin panel is server-rendered, but it's all one Astro app running on Cloudflare's edge network. The linked blog post does go into these details. Maybe I need to make it more clear.
Ref: https://developers.cloudflare.com/pages/framework-guides/deploy-an-astro-site/
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Astro 5 | Hybrid SSG/SSR |
| Hosting | Cloudflare Pages | Edge deployment |
| Database | Cloudflare D1 | SQLite at the edge |
| Storage | Cloudflare R2 | Image hosting |
| Styling | Vanilla CSS | No build overhead |
| Syntax Highlighting | Shiki + Prism.js | Code blocks |
| Diagrams | Mermaid.js | Architecture diagrams |
Total monthly cost: $0 (within free tier limits)
Architecture Overview
Hybrid Rendering Strategy
Astro's killer feature is hybrid rendering — choose SSG or SSR per route:
// astro.config.mjs
export default defineConfig({
output: 'server', // SSR by default
adapter: cloudflare({
platformProxy: { enabled: true }
})
});
Static Routes (Pre-rendered)
---
// src/pages/blog/[slug].astro
export const prerender = true; // Generate at build time
export async function getStaticPaths() {
const posts = await db.prepare(
'SELECT slug FROM posts WHERE status = ?'
).bind('published').all();
return posts.results.map(post => ({
params: { slug: post.slug }
}));
}
---
Dynamic Routes (SSR)
---
// src/pages/admin/posts/[id].astro
// No prerender = SSR by default
const { id } = Astro.params;
const post = await db.prepare(
'SELECT * FROM posts WHERE id = ?'
).bind(id).first();
---
Result: Blog posts are static HTML (fast), admin panel is dynamic (secure).
Database Schema
D1 is SQLite at the edge — simple, fast, and free:
CREATE TABLE posts (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT NOT NULL,
cover_image TEXT,
status TEXT DEFAULT 'draft',
tags TEXT, -- JSON array
published_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE images (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
url TEXT NOT NULL,
size INTEGER,
mime_type TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_slug ON posts(slug);
Querying from Astro
// Access D1 via Cloudflare runtime
const runtime = Astro.locals.runtime;
const db = runtime.env.DB;
const posts = await db.prepare(`
SELECT * FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
`).all();
Image Upload Flow
Paste-to-Upload with WebP Compression
The killer feature: paste an image from clipboard, it auto-compresses to WebP and uploads.
Here's a an image copy-pasted from the blog's PageSpeed Insights:

Console shows the compression stats:
Compressed: pasted-image-1771781692758.png (265.6KB) → pasted-image-1771781692758.webp (65.2KB)
// Client-side compression before upload
async function compressImage(file, maxWidth = 1600, quality = 0.85) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file; // Skip GIFs to preserve animation
}
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxWidth / bitmap.width);
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, width, height);
// Convert to WebP at 85% quality
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality
});
return new File([blob], file.name.replace(/\.\w+$/, '.webp'), {
type: 'image/webp'
});
}
Handling Paste Events
// Listen for paste in the markdown editor
contentArea.addEventListener('paste', async (e) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
const compressed = await compressImage(file);
// Upload and insert markdown
const { url } = await uploadToR2(compressed);
insertAtCursor(``);
return;
}
}
});
Result: Screenshot → Paste → Auto-compressed WebP → Markdown inserted. No manual steps.
Upload Endpoint
// src/pages/admin/api/upload.ts
export const POST: APIRoute = async ({ request, locals }) => {
const bucket = locals.runtime.env.BLOG_ASSETS;
const formData = await request.formData();
const file = formData.get('file') as File;
// Generate unique filename
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
const key = `blog/${timestamp}-${random}.webp`;
// Upload to R2
await bucket.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type }
});
return new Response(JSON.stringify({
url: `https://blog-assets.scopecreeplabs.com/${key}`
}));
};
The Admin Panel
A custom CMS built with Astro SSR:
Authentication
Simple password protection via environment variable:
// src/middleware.ts
export const onRequest = async ({ request, locals, redirect }, next) => {
if (request.url.includes('/admin')) {
const auth = request.headers.get('Authorization');
const expected = `Basic ${btoa(`admin:${locals.runtime.env.ADMIN_PASSWORD}`)}`;
if (auth !== expected) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Admin"' }
});
}
}
return next();
};
Deploy Hook Architecture
When content changes, the static pages need rebuilding. The admin panel shows real-time build status:
Rebuild Endpoint
Triggering a rebuild is simple — just POST to a Cloudflare deploy hook:
// src/pages/admin/api/rebuild.ts
export const POST: APIRoute = async ({ locals }) => {
const hookUrl = locals.runtime.env.DEPLOY_HOOK_URL;
const response = await fetch(hookUrl, { method: 'POST' });
if (response.ok) {
return new Response(JSON.stringify({
success: true,
message: 'Rebuild triggered'
}));
}
return new Response(JSON.stringify({
error: 'Failed to trigger rebuild'
}), { status: 500 });
};
Deploy Status Endpoint
The admin panel polls the Cloudflare Pages API to show real-time build status:
// src/pages/admin/api/deploy-status.ts
export const GET: APIRoute = async ({ locals }) => {
const accountId = locals.runtime.env.CF_ACCOUNT_ID;
const apiToken = locals.runtime.env.CF_API_TOKEN;
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/scopecreeplabs-site/deployments`,
{ headers: { 'Authorization': `Bearer ${apiToken}` } }
);
const data = await response.json();
const latest = data.result[0];
return new Response(JSON.stringify({
status: latest.latest_stage.status,
stageName: latest.latest_stage.name,
createdAt: latest.created_on,
isBuilding: ['queued', 'active'].includes(latest.latest_stage.status)
}));
};
Status Indicator UI
The header shows build status with visual feedback:
- Green "Live" — Last build succeeded, shows relative time
- Yellow "Building" — Build in progress, button disabled
- Red "Failed" — Last build failed
// Poll every 5s during builds, 30s otherwise
async function checkDeployStatus() {
const { status, isBuilding, createdAt } = await fetch('/admin/api/deploy-status').then(r => r.json());
if (isBuilding) {
statusEl.className = 'deploy-status building';
statusEl.innerHTML = '<span class="status-dot"></span> Building...';
rebuildBtn.disabled = true;
} else {
statusEl.className = `deploy-status ${status === 'success' ? 'success' : 'failure'}`;
statusEl.innerHTML = `<span class="status-dot"></span> ${status === 'success' ? 'Live' : 'Failed'} · ${formatTime(createdAt)}`;
rebuildBtn.disabled = false;
}
}
This prevents accidental double-triggers and gives confidence that deploys are working.
Markdown Processing
Build-time Syntax Highlighting
Astro uses Shiki for build-time highlighting — zero JS shipped to client:
// astro.config.mjs
export default defineConfig({
markdown: {
syntaxHighlight: 'shiki',
shikiConfig: {
theme: 'github-dark',
wrap: true
}
}
});
Runtime Preview Highlighting
For the live preview (before build), we use Prism.js:
// src/pages/admin/api/preview.ts
import { marked } from 'marked';
export const POST: APIRoute = async ({ request }) => {
const { content } = await request.json();
// Convert markdown to HTML
const html = await marked(content);
return new Response(JSON.stringify({ html }));
};
<!-- Client-side highlighting after content loads -->
<script>
if (window.Prism) {
Prism.highlightAllUnder(document.getElementById('preview-content'));
}
if (window.mermaid) {
mermaid.run({ nodes: document.querySelectorAll('.mermaid') });
}
</script>
Mermaid Diagrams
Diagrams render client-side from fenced code blocks:
```mermaid
flowchart LR
A[Start] --> B[Process] --> C[End]
```
// Initialize Mermaid with theme detection
document.addEventListener('DOMContentLoaded', () => {
mermaid.initialize({
startOnLoad: true,
theme: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'default'
});
});
YouTube Embeds
Just paste a YouTube URL on its own line, and it auto-embeds:
Check out this video:
https://youtu.be/W3X0N3q3Ds0
More content below...
The markdown processor detects standalone YouTube URLs and converts them to responsive iframes:
// Post-process YouTube URLs wrapped in <a> tags (GFM autolinks)
html = html.replace(
/<p>\s*<a href="(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&][^"]*)?">.*?<\/a>\s*<\/p>/g,
`<div class="youtube-embed">
<iframe src="https://www.youtube-nocookie.com/embed/$1" ...></iframe>
</div>`
);
Key details:
- Uses
youtube-nocookie.comfor privacy-enhanced embeds - Handles both
youtu.be/IDandyoutube.com/watch?v=IDformats - Strips tracking parameters (like
?si=...) automatically - Inline links remain as links — only standalone URLs embed
/* Responsive 16:9 container */
.youtube-embed {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin: 2rem 0;
border-radius: var(--radius);
}
.youtube-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
Cloudflare Bindings
All services connected via wrangler.jsonc:
{
"name": "scopecreeplabs-site",
"compatibility_date": "2024-12-01",
"pages_build_output_dir": "./dist",
"d1_databases": [{
"binding": "DB",
"database_name": "scopecreeplabs-blog",
"database_id": "eb478fdb-..."
}],
"r2_buckets": [{
"binding": "IMAGES",
"bucket_name": "scopecreeplabs-images"
}],
"vars": {
"SITE_URL": "https://scopecreeplabs.com"
}
}
Performance Results
| Metric | Value |
|---|---|
| Blog post TTFB | ~50ms (edge cached) |
| Admin panel TTFB | ~100ms (SSR) |
| Lighthouse score | 100/100 |
| Build time | ~30 seconds |
| Deploy time | ~10 seconds |
Cost Breakdown
| Service | Free Tier | Our Usage |
|---|---|---|
| Pages | Unlimited static requests | ✓ |
| D1 | 5M reads/day, 100K writes/day | ~100/day |
| R2 | 10GB storage, 10M reads/month | ~50MB |
| Workers | 100K requests/day | ~50/day (SSR + API) |
Monthly bill: $0.00
Key Takeaways
- Hybrid rendering is powerful — Static for readers, dynamic for authors
- Cloudflare's free tier is generous — D1 + R2 + Pages covers most use cases
- Build your own CMS — It's simpler than adopting one, if your requirements are simple :-)
- Edge computing is fast — 50ms TTFB from anywhere in the world
- SQLite scales surprisingly well — D1 handles our queries effortlessly
What's Next
- RSS feed generation
- Draft preview links
- Scheduled publishing
This blog runs on ~500 lines of Astro code, costs nothing, and deploys in seconds.
Feedback? Comments? Join the discussion on this Reddit thread: https://www.reddit.com/r/astrojs/comments/1rbsb6t/building_a_serverless_blog_with_astro_cloudflare/