Blog with D1 Database
In this tutorial, you’ll build a complete blog application with:
- D1 Database for storing posts
- Tailwind CSS for styling
- Static Site Generation for fast page loads
- Dynamic routing for individual post pages
Project Overview
Section titled “Project Overview”The final project structure:
Directoryapp/
- layout.tsx
- page.tsx
- globals.css
Directoryabout/
- page.tsx
Directoryposts/
Directory[slug]/
- page.tsx
Directorylib/
- db.ts
- markdown.ts
Directorycomponents/
- PostCard.tsx
Directorymigrations/
- 0001_create_posts.sql
- cloudwerk.config.ts
- wrangler.toml
- package.json
Step 1: Create the Project
Section titled “Step 1: Create the Project”-
Create a new Cloudwerk app:
Terminal window pnpm dlx @cloudwerk/create-app blog --renderer hono-jsxcd blog -
Install dependencies:
Terminal window pnpm install
Step 2: Set Up D1 Database
Section titled “Step 2: Set Up D1 Database”The starter template already includes Tailwind CSS configured in app/globals.css.
-
Add a D1 database binding using the Cloudwerk CLI:
Terminal window npm run bindings add d1When prompted:
- Binding name:
DB - Database name:
blog-db - Add to wrangler.toml?: Yes
This creates the database and automatically configures
wrangler.toml. - Binding name:
-
Generate TypeScript types for your bindings:
Terminal window npm run bindings generate-typesThis creates type definitions so you can import
DBfrom@cloudwerk/core/bindings. -
Create a migration file:
Terminal window wrangler d1 migrations create blog-db create_postsThis creates
migrations/0001_create_posts.sql.
Database Schema
Section titled “Database Schema”Open the migration file and add the posts table with seed data:
CREATE TABLE posts ( id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, excerpt TEXT, content TEXT NOT NULL, published_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_posts_slug ON posts(slug);CREATE INDEX idx_posts_published ON posts(published_at);
-- Seed dataINSERT INTO posts (id, slug, title, excerpt, content, published_at) VALUES ('1', 'hello-world', 'Hello World', 'Welcome to my blog built with Cloudwerk.', '# Hello World
Welcome to my blog! This is my first post built with **Cloudwerk**.
## What is Cloudwerk?
Cloudwerk provides file-based routing that compiles to Hono, with integrated support for:
- D1 databases- KV storage- R2 object storage- Authentication- Queues
Stay tuned for more posts!', '2025-01-15'),
('2', 'getting-started', 'Getting Started with Cloudwerk', 'Learn how to build your first app.', '# Getting Started with Cloudwerk
In this post, we will walk through building your first Cloudwerk application.
## Prerequisites
- Node.js 20+- pnpm (recommended)- A Cloudflare account
## Create Your App
```bashpnpm dlx @cloudwerk/create-app my-blog --renderer hono-jsxcd my-blogpnpm installVisit http://localhost:3000 to see your app!’,
‘2025-01-20’);
Apply the migration locally:
```bashwrangler d1 migrations apply blog-db --localStep 3: Create Database Helpers
Section titled “Step 3: Create Database Helpers”Create a module to interact with the D1 database:
import { DB } from '@cloudwerk/core/bindings'
export interface Post { id: string slug: string title: string excerpt: string | null content: string published_at: string | null created_at: string}
export async function getPosts(): Promise<Post[]> { const result = await DB .prepare( 'SELECT id, slug, title, excerpt, published_at, created_at FROM posts WHERE published_at IS NOT NULL ORDER BY published_at DESC' ) .all<Post>() return result.results}
export async function getPostBySlug(slug: string): Promise<Post | null> { const result = await DB .prepare('SELECT * FROM posts WHERE slug = ?') .bind(slug) .first<Post>() return result}
export async function getAllSlugs(): Promise<string[]> { const result = await DB .prepare('SELECT slug FROM posts WHERE published_at IS NOT NULL') .all<{ slug: string }>() return result.results.map((r: { slug: string }) => r.slug)}Step 4: Create a Markdown Renderer
Section titled “Step 4: Create a Markdown Renderer”Create a simple markdown renderer for blog posts:
type MarkdownNode = { type: string content?: string level?: number lang?: string items?: string[]}
function parseMarkdown(source: string): MarkdownNode[] { const lines = source.split('\n') const nodes: MarkdownNode[] = [] let i = 0
while (i < lines.length) { const line = lines[i]
// Headers const headerMatch = line.match(/^(#{1,6})\s+(.+)$/) if (headerMatch) { nodes.push({ type: 'heading', level: headerMatch[1].length, content: headerMatch[2], }) i++ continue }
// Code blocks if (line.startsWith('```')) { const lang = line.slice(3).trim() const codeLines: string[] = [] i++ while (i < lines.length && !lines[i].startsWith('```')) { codeLines.push(lines[i]) i++ } nodes.push({ type: 'code', lang: lang || undefined, content: codeLines.join('\n'), }) i++ continue }
// Unordered lists if (line.match(/^[-*]\s+/)) { const items: string[] = [] while (i < lines.length && lines[i].match(/^[-*]\s+/)) { items.push(lines[i].replace(/^[-*]\s+/, '')) i++ } nodes.push({ type: 'list', items }) continue }
// Empty lines if (line.trim() === '') { i++ continue }
// Paragraphs const paragraphLines: string[] = [] while (i < lines.length && lines[i].trim() !== '' && !lines[i].match(/^[#\-*`]/)) { paragraphLines.push(lines[i]) i++ } if (paragraphLines.length > 0) { nodes.push({ type: 'paragraph', content: paragraphLines.join(' ') }) } }
return nodes}
function formatInlineText(text: string): string { return text .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.+?)\*/g, '<em>$1</em>') .replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-sm">$1</code>')}
function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>')}
export function renderMarkdown(source: string): string { const nodes = parseMarkdown(source) const parts: string[] = []
for (const node of nodes) { switch (node.type) { case 'heading': { const Tag = `h${node.level}` const classes: Record<number, string> = { 1: 'text-3xl font-bold mt-8 mb-4', 2: 'text-2xl font-semibold mt-6 mb-3', 3: 'text-xl font-medium mt-4 mb-2', } parts.push( `<${Tag} class="${classes[node.level!] || 'font-medium mt-4 mb-2'}">${formatInlineText(node.content!)}</${Tag}>` ) break } case 'paragraph': parts.push( `<p class="mb-4 leading-relaxed">${formatInlineText(node.content!)}</p>` ) break case 'code': parts.push( `<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"><code>${escapeHtml(node.content!)}</code></pre>` ) break case 'list': parts.push('<ul class="list-disc list-inside mb-4 space-y-1">') for (const item of node.items!) { parts.push(`<li>${formatInlineText(item)}</li>`) } parts.push('</ul>') break } }
return parts.join('\n')}Step 5: Update the Root Layout
Section titled “Step 5: Update the Root Layout”Update the layout to set the blog title:
import type { LayoutProps } from '@cloudwerk/core'import globals from './globals.css?url'
export default function RootLayout({ children }: LayoutProps) { return ( <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Blog - Cloudwerk</title> <link rel="stylesheet" href={globals} /> </head> <body> {children} </body> </html> )}Step 6: Create the Home Page
Section titled “Step 6: Create the Home Page”The home page displays a list of all published posts:
import type { PageProps, LoaderArgs } from '@cloudwerk/core'import { getPosts, type Post } from './lib/db'import PostCard from './components/PostCard'
export async function loader(_args: LoaderArgs) { const posts = await getPosts() return { posts }}
interface HomePageProps extends PageProps { posts: Post[]}
export default function HomePage({ posts }: HomePageProps) { return ( <div class="min-h-screen bg-gray-100 py-12 px-4"> <div class="max-w-2xl mx-auto"> <header class="mb-12"> <h1 class="text-4xl font-bold text-gray-900 mb-4">My Blog</h1> <p class="text-gray-600"> A personal blog built with Cloudwerk and Cloudflare Workers. </p> <nav class="mt-4 flex gap-4"> <a href="/" class="text-blue-600 hover:underline">Home</a> <a href="/about" class="text-blue-600 hover:underline">About</a> </nav> </header>
<main> <h2 class="text-2xl font-semibold text-gray-800 mb-6">Latest Posts</h2> {posts.length > 0 ? ( <div class="space-y-6"> {posts.map((post) => ( <PostCard post={post} /> ))} </div> ) : ( <p class="text-gray-500">No posts yet.</p> )} </main> </div> </div> )}Step 7: Create the PostCard Component
Section titled “Step 7: Create the PostCard Component”Create a reusable component to display post previews:
import type { Post } from '../lib/db'
interface PostCardProps { post: Omit<Post, 'content'>}
export default function PostCard({ post }: PostCardProps) { const date = post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }) : null
return ( <article class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"> <a href={`/posts/${post.slug}`} class="block p-6"> <h2 class="text-xl font-semibold text-gray-900 mb-2 hover:text-blue-600"> {post.title} </h2> {post.excerpt && <p class="text-gray-600 mb-4">{post.excerpt}</p>} {date && <time class="text-sm text-gray-500">{date}</time>} </a> </article> )}Step 8: Create the Post Detail Page
Section titled “Step 8: Create the Post Detail Page”This page uses Static Site Generation (SSG) to pre-render posts at build time:
import type { PageProps, LoaderArgs } from '@cloudwerk/core'import { NotFoundError } from '@cloudwerk/core'import { raw } from 'hono/html'import { getPostBySlug, getAllSlugs, type Post } from '../../lib/db'import { renderMarkdown } from '../../lib/markdown'
// Enable static site generationexport const config = { rendering: 'static',}
// Generate static paths for all postsexport async function generateStaticParams() { const slugs = await getAllSlugs() return slugs.map((slug) => ({ slug }))}
export async function loader({ params }: LoaderArgs) { const post = await getPostBySlug(params.slug) if (!post) { throw new NotFoundError(`Post not found: ${params.slug}`) } const html = renderMarkdown(post.content) return { post, html }}
interface PostPageProps extends PageProps { post: Post html: string}
export default function PostPage({ post, html }: PostPageProps) { const date = post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }) : null
return ( <div class="min-h-screen bg-gray-100 py-12 px-4"> <div class="max-w-2xl mx-auto"> <nav class="mb-8"> <a href="/" class="text-blue-600 hover:underline"> ← Back to all posts </a> </nav>
<article class="bg-white rounded-lg shadow-md p-8"> <header class="mb-8 border-b pb-6"> <h1 class="text-3xl font-bold text-gray-900 mb-2">{post.title}</h1> {date && <time class="text-gray-500">{date}</time>} </header>
<div class="prose prose-gray max-w-none">{raw(html)}</div> </article> </div> </div> )}Step 9: Create the About Page
Section titled “Step 9: Create the About Page”Add a static about page:
export default function AboutPage() { return ( <div class="min-h-screen bg-gray-100 py-12 px-4"> <div class="max-w-2xl mx-auto"> <nav class="mb-8 flex gap-4"> <a href="/" class="text-blue-600 hover:underline">Home</a> <a href="/about" class="text-blue-600 hover:underline font-semibold">About</a> </nav>
<article class="bg-white rounded-lg shadow-md p-8"> <h1 class="text-3xl font-bold text-gray-900 mb-6">About</h1>
<div class="prose prose-gray max-w-none"> <p class="mb-4 leading-relaxed"> Welcome to my blog! This is a demo application built with{' '} <strong>Cloudwerk</strong>, a full-stack framework for Cloudflare Workers. </p>
<h2 class="text-2xl font-semibold mt-6 mb-3">Features</h2> <ul class="list-disc list-inside mb-4 space-y-1"> <li>File-based routing with dynamic segments</li> <li>Server-side rendering with Hono JSX</li> <li>D1 database integration</li> <li>Static site generation for blog posts</li> <li>Tailwind CSS styling</li> </ul>
<h2 class="text-2xl font-semibold mt-6 mb-3">Technology Stack</h2> <ul class="list-disc list-inside mb-4 space-y-1"> <li>Cloudwerk framework</li> <li>Cloudflare Workers</li> <li>Cloudflare D1 (SQLite)</li> <li>Hono JSX</li> <li>Tailwind CSS v4</li> </ul> </div> </article> </div> </div> )}Step 10: Run the Development Server
Section titled “Step 10: Run the Development Server”Start the development server:
pnpm devVisit http://localhost:3000 to see your blog!
Step 11: Build and Deploy
Section titled “Step 11: Build and Deploy”-
Build the application:
Terminal window pnpm buildThis will:
- Bundle your server code
- Process Tailwind CSS
- Pre-render static pages (posts)
- Output everything to
dist/
-
Apply migrations to production:
Terminal window wrangler d1 migrations apply blog-db --remote -
Deploy to Cloudflare:
Terminal window pnpm deploy
Summary
Section titled “Summary”You’ve built a complete blog with:
- D1 Database: Storing posts with full-text content and metadata
- Type-safe bindings: Using
@cloudwerk/core/bindingsfor database access - Static Site Generation: Pre-rendering blog posts for instant loads
- Dynamic routing: Using
[slug]for post URLs - Tailwind CSS: Modern, utility-first styling
- Server-side rendering: Fast initial page loads
Next Steps
Section titled “Next Steps”- Database Guide - Learn more about D1 patterns
- Data Loading - Advanced loader patterns
- Authentication - Add user authentication
- Deployment - Production deployment options