Skip to content

Blog Example

Build a complete blog application with user authentication, markdown support, comments, and more.

This example demonstrates:

  • User authentication with sessions
  • CRUD operations with D1
  • Markdown rendering
  • Comments system
  • SEO optimization
  • Directoryapp/
    • page.tsx # Homepage with recent posts
    • layout.tsx # Root layout
    • middleware.ts # Auth middleware
    • Directory(auth)/
      • Directorylogin/
        • page.tsx
      • Directoryregister/
        • page.tsx
      • Directorylogout/
        • route.ts
    • Directoryposts/
      • page.tsx # Posts list
      • Directorynew/
        • page.tsx # Create post (auth required)
      • Directory[slug]/
        • page.tsx # Post detail
        • Directoryedit/
          • page.tsx # Edit post (auth required)
    • Directoryapi/
      • Directoryposts/
        • route.ts
        • Directory[slug]/
          • route.ts
          • Directorycomments/
            • route.ts
  • Directorymigrations/
    • 0001_create_users.sql
    • 0002_create_posts.sql
    • 0003_create_comments.sql
-- migrations/0001_create_users.sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
bio TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
-- migrations/0002_create_posts.sql
CREATE TABLE posts (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
author_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_published ON posts(published);
-- migrations/0003_create_comments.sql
CREATE TABLE comments (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
post_id TEXT NOT NULL REFERENCES posts(id),
author_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_comments_post ON comments(post_id);
// app/layout.tsx
import type { LayoutProps, LoaderArgs } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) {
const session = context.req.cookie('session');
const user = session ? context.get('user') : null;
return { user };
}
export default function RootLayout({ children, user }: LayoutProps & { user: User | null }) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Blog</title>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/posts">Posts</a>
{user ? (
<>
<a href="/posts/new">Write</a>
<form action="/logout" method="POST">
<button type="submit">Logout</button>
</form>
</>
) : (
<>
<a href="/login">Login</a>
<a href="/register">Register</a>
</>
)}
</nav>
</header>
<main>{children}</main>
<footer>
<p>Built with Cloudwerk</p>
</footer>
</body>
</html>
);
}
// app/page.tsx
import type { PageProps, LoaderArgs } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) {
const db = context.env.DB;
const { results: posts } = await db
.prepare(`
SELECT
posts.slug,
posts.title,
posts.excerpt,
posts.created_at,
users.name as author_name
FROM posts
INNER JOIN users ON users.id = posts.author_id
WHERE posts.published = 1
ORDER BY posts.created_at DESC
LIMIT 10
`)
.all();
return { posts };
}
export default function HomePage({ posts }: PageProps & { posts: Post[] }) {
return (
<div>
<h1>Welcome to My Blog</h1>
<section>
<h2>Recent Posts</h2>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/posts/${post.slug}`}>
<h3>{post.title}</h3>
</a>
<p>{post.excerpt}</p>
<small>By {post.author_name} on {new Date(post.created_at).toLocaleDateString()}</small>
</li>
))}
</ul>
</section>
</div>
);
}
// app/posts/[slug]/page.tsx
import type { PageProps, LoaderArgs } from '@cloudwerk/core';
import { NotFoundError } from '@cloudwerk/core';
export async function loader({ params, context }: LoaderArgs) {
const db = context.env.DB;
const post = await db
.prepare(`
SELECT
posts.id,
posts.slug,
posts.title,
posts.content,
posts.created_at,
posts.author_id,
users.name as author_name,
users.avatar_url as author_avatar
FROM posts
INNER JOIN users ON users.id = posts.author_id
WHERE posts.slug = ?
AND posts.published = 1
`)
.bind(params.slug)
.first();
if (!post) {
throw new NotFoundError('Post not found');
}
const { results: comments } = await db
.prepare(`
SELECT
comments.id,
comments.content,
comments.created_at,
users.name as author_name
FROM comments
INNER JOIN users ON users.id = comments.author_id
WHERE comments.post_id = ?
ORDER BY comments.created_at ASC
`)
.bind(post.id)
.all();
const user = context.get('user');
return { post, comments, user };
}
export default function PostPage({ post, comments, user }: PageProps & LoaderData) {
return (
<article>
<header>
<h1>{post.title}</h1>
<div>
By {post.author_name} on {new Date(post.created_at).toLocaleDateString()}
</div>
</header>
<div>{post.content}</div>
<section>
<h2>Comments ({comments.length})</h2>
{user && (
<form action={`/api/posts/${post.slug}/comments`} method="POST">
<textarea name="content" placeholder="Write a comment..." required />
<button type="submit">Post Comment</button>
</form>
)}
<ul>
{comments.map((comment) => (
<li key={comment.id}>
<strong>{comment.author_name}</strong>
<p>{comment.content}</p>
<small>{new Date(comment.created_at).toLocaleDateString()}</small>
</li>
))}
</ul>
</section>
</article>
);
}
// app/posts/new/page.tsx
import type { PageProps, LoaderArgs } from '@cloudwerk/core';
import { RedirectError } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) {
const user = context.get('user');
if (!user) {
throw new RedirectError('/login');
}
return {};
}
export default function NewPostPage() {
return (
<div>
<h1>Create New Post</h1>
<form action="/api/posts" method="POST">
<div>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="excerpt">Excerpt</label>
<textarea id="excerpt" name="excerpt" rows={2} />
</div>
<div>
<label htmlFor="content">Content (Markdown)</label>
<textarea id="content" name="content" rows={20} required />
</div>
<div>
<label>
<input type="checkbox" name="published" value="true" />
Publish immediately
</label>
</div>
<button type="submit">Create Post</button>
</form>
</div>
);
}
// app/api/posts/route.ts
import type { CloudwerkHandlerContext } from '@cloudwerk/core';
import { json, getContext } from '@cloudwerk/core';
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
export async function POST(request: Request, { params }: CloudwerkHandlerContext) {
const ctx = getContext();
const user = ctx.get('user');
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const title = formData.get('title') as string;
const excerpt = formData.get('excerpt') as string;
const content = formData.get('content') as string;
const published = formData.get('published') === 'true';
const id = crypto.randomUUID();
const slug = slugify(title) + '-' + Date.now().toString(36);
await ctx.env.DB
.prepare(`
INSERT INTO posts (id, slug, title, excerpt, content, published, author_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
.bind(id, slug, title, excerpt, content, published ? 1 : 0, user.id)
.run();
return new Response(null, {
status: 302,
headers: { 'Location': `/posts/${slug}` },
});
}
// app/api/posts/[slug]/comments/route.ts
import type { CloudwerkHandlerContext } from '@cloudwerk/core';
import { json, getContext } from '@cloudwerk/core';
export async function POST(request: Request, { params }: CloudwerkHandlerContext) {
const ctx = getContext();
const user = ctx.get('user');
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const post = await ctx.env.DB
.prepare('SELECT id FROM posts WHERE slug = ?')
.bind(params.slug)
.first();
if (!post) {
return json({ error: 'Post not found' }, { status: 404 });
}
const formData = await request.formData();
const content = formData.get('content') as string;
const id = crypto.randomUUID();
await ctx.env.DB
.prepare('INSERT INTO comments (id, content, post_id, author_id) VALUES (?, ?, ?, ?)')
.bind(id, content, post.id, user.id)
.run();
return new Response(null, {
status: 302,
headers: { 'Location': `/posts/${params.slug}` },
});
}
  1. Clone or create the project:

    Terminal window
    pnpm dlx @cloudwerk/create-app my-blog --template blog
  2. Set up the database:

    Terminal window
    wrangler d1 create my-blog-db
    wrangler d1 migrations apply my-blog-db --local
  3. Start development:

    Terminal window
    pnpm dev
  4. Deploy:

    Terminal window
    pnpm deploy
  • Add markdown parsing with a library like marked
  • Implement post categories and tags
  • Add RSS feed generation
  • Implement search functionality
  • Add social sharing buttons