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
flowchart LR subgraph User["Reader"] BROWSER[Browser] end subgraph CF["Cloudflare Edge"] PAGES[Pages<br/>Static + SSR] D1[(D1 Database)] R2[(R2 Storage)] end subgraph Admin["Author"] ADMIN[Admin Panel] end BROWSER -->|Fast static| PAGES ADMIN -->|SSR| PAGES PAGES <-->|Query| D1 PAGES <-->|Images| R2

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

flowchart TB subgraph Build["Build Time (npm run build)"] ASTRO[Astro Compiler] SHIKI[Shiki Highlighter] STATIC[Static HTML] end subgraph Runtime["Runtime (Cloudflare Edge)"] WORKER[Pages Worker] D1[(D1 Database)] R2[(R2 Bucket)] end subgraph Routes["Route Types"] SSG["/blog/* - SSG<br/>Pre-rendered at build"] SSR["/admin/* - SSR<br/>Dynamic at request"] API["/admin/api/* - API<br/>Server endpoints"] end ASTRO --> SHIKI --> STATIC STATIC --> WORKER WORKER --> SSG WORKER --> SSR WORKER --> API SSR <--> D1 API <--> D1 API <--> R2

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

sequenceDiagram participant Admin as Admin Panel participant API as /admin/api/upload participant R2 as R2 Bucket participant DB as D1 Database Admin->>API: POST image file API->>API: Generate unique filename API->>R2: PUT object R2-->>API: Success API->>DB: INSERT image record DB-->>API: Success API-->>Admin: { url: "https://..." } Admin->>Admin: Insert into editor

Paste-to-Upload with WebP Compression

The killer feature: paste an image from clipboard, it auto-compresses to WebP and uploads.

flowchart LR subgraph Client["Browser"] PASTE["Ctrl+V"] CANVAS[OffscreenCanvas] WEBP[WebP Blob] end subgraph Server["Cloudflare"] API[Upload API] R2[(R2 Bucket)] end PASTE -->|PNG 2.4MB| CANVAS CANVAS -->|Compress| WEBP WEBP -->|WebP 180KB| API API --> R2

Here's a an image copy-pasted from the blog's PageSpeed Insights:

pasted image

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(`![](${url})`);
      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:

flowchart TB subgraph Admin["Admin Panel"] LIST[Posts List<br/>/admin/posts] EDIT[Edit Post<br/>/admin/posts/:id] NEW[New Post<br/>/admin/posts/new] PREVIEW[Live Preview<br/>/admin/preview/:id] end subgraph Features["Features"] MD[Markdown Editor] IMG[Image Upload] TAGS[Tag Management] PUB[Publish/Draft] end LIST --> EDIT LIST --> NEW EDIT --> PREVIEW NEW --> PREVIEW EDIT --> MD MD --> IMG MD --> TAGS MD --> PUB

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:

sequenceDiagram participant Admin as Admin Panel participant API as /admin/api/rebuild participant Hook as Cloudflare Deploy Hook participant Build as Pages Build participant Edge as Edge Network Admin->>API: Click "Rebuild Site" API->>Hook: POST (trigger) Hook-->>API: 200 OK API-->>Admin: "Rebuild triggered" Note over Build: Async build starts Build->>Build: npm run build Build->>Build: Fetch posts from D1 Build->>Build: Generate static HTML Build->>Edge: Deploy to 300+ locations Note over Edge: New content live globally

Rebuild Endpoint

// 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 });
};

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.com for privacy-enhanced embeds
  • Handles both youtu.be/ID and youtube.com/watch?v=ID formats
  • 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

  1. Hybrid rendering is powerful — Static for readers, dynamic for authors
  2. Cloudflare's free tier is generous — D1 + R2 + Pages covers most use cases
  3. Build your own CMS — It's simpler than adopting one, if your requirements are simple :-)
  4. Edge computing is fast — 50ms TTFB from anywhere in the world
  5. 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.