Blog Example
Build a complete blog application with user authentication, markdown support, comments, and more.
Overview
Section titled “Overview”This example demonstrates:
- User authentication with sessions
- CRUD operations with D1
- Markdown rendering
- Comments system
- SEO optimization
Project Structure
Section titled “Project Structure”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
Database Schema
Section titled “Database Schema”Users Table
Section titled “Users Table”-- migrations/0001_create_users.sqlCREATE 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);Posts Table
Section titled “Posts Table”-- migrations/0002_create_posts.sqlCREATE 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);Comments Table
Section titled “Comments Table”-- migrations/0003_create_comments.sqlCREATE 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);Implementation
Section titled “Implementation”Root Layout
Section titled “Root Layout”// app/layout.tsximport type { LayoutProps, LoaderArgs } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) { const user = await context.auth.getUser(); 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> );}Homepage
Section titled “Homepage”// app/page.tsximport type { PageProps, LoaderArgs } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) { const posts = await context.db .selectFrom('posts') .innerJoin('users', 'users.id', 'posts.author_id') .select([ 'posts.slug', 'posts.title', 'posts.excerpt', 'posts.created_at', 'users.name as authorName', ]) .where('posts.published', '=', true) .orderBy('posts.created_at', 'desc') .limit(10) .execute();
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.authorName} on {new Date(post.created_at).toLocaleDateString()}</small> </li> ))} </ul> </section> </div> );}Post Detail Page
Section titled “Post Detail Page”// app/posts/[slug]/page.tsximport type { PageProps, LoaderArgs } from '@cloudwerk/core';import { NotFoundError } from '@cloudwerk/core';
export async function loader({ params, context }: LoaderArgs) { const post = await context.db .selectFrom('posts') .innerJoin('users', 'users.id', 'posts.author_id') .select([ 'posts.id', 'posts.slug', 'posts.title', 'posts.content', 'posts.created_at', 'posts.author_id', 'users.name as authorName', 'users.avatar_url as authorAvatar', ]) .where('posts.slug', '=', params.slug) .where('posts.published', '=', true) .executeTakeFirst();
if (!post) { throw new NotFoundError('Post not found'); }
const comments = await context.db .selectFrom('comments') .innerJoin('users', 'users.id', 'comments.author_id') .select([ 'comments.id', 'comments.content', 'comments.created_at', 'users.name as authorName', ]) .where('comments.post_id', '=', post.id) .orderBy('comments.created_at', 'asc') .execute();
const user = await context.auth.getUser();
return { post, comments, user };}
export default function PostPage({ post, comments, user }: PageProps & LoaderData) { return ( <article> <header> <h1>{post.title}</h1> <div> By {post.authorName} 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.authorName}</strong> <p>{comment.content}</p> <small>{new Date(comment.created_at).toLocaleDateString()}</small> </li> ))} </ul> </section> </article> );}Create Post Page
Section titled “Create Post Page”// app/posts/new/page.tsximport type { PageProps, LoaderArgs } from '@cloudwerk/core';
export async function loader({ context }: LoaderArgs) { await context.auth.requireUser(); 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> );}Posts API Route
Section titled “Posts API Route”// app/api/posts/route.tsimport { json, redirect } from '@cloudwerk/core';import type { CloudwerkHandlerContext } from '@cloudwerk/core';
function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '');}
export async function POST(request: Request, { context }: CloudwerkHandlerContext) { const user = await context.auth.requireUser();
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 slug = slugify(title) + '-' + Date.now().toString(36);
await context.db .insertInto('posts') .values({ id: crypto.randomUUID(), slug, title, excerpt, content, published, author_id: user.id, }) .execute();
return redirect(`/posts/${slug}`);}Comments API Route
Section titled “Comments API Route”// app/api/posts/[slug]/comments/route.tsimport { json, redirect } from '@cloudwerk/core';import type { CloudwerkHandlerContext } from '@cloudwerk/core';
export async function POST(request: Request, { params, context }: CloudwerkHandlerContext) { const user = await context.auth.requireUser();
const post = await context.db .selectFrom('posts') .select(['id']) .where('slug', '=', params.slug) .executeTakeFirst();
if (!post) { return json({ error: 'Post not found' }, { status: 404 }); }
const formData = await request.formData(); const content = formData.get('content') as string;
await context.db .insertInto('comments') .values({ id: crypto.randomUUID(), content, post_id: post.id, author_id: user.id, }) .execute();
return redirect(`/posts/${params.slug}`);}Running the Example
Section titled “Running the Example”-
Clone or create the project:
Terminal window pnpm create cloudwerk@latest my-blog --template blog -
Set up the database:
Terminal window wrangler d1 create my-blog-dbwrangler d1 migrations apply my-blog-db --local -
Start development:
Terminal window pnpm dev -
Deploy:
Terminal window pnpm deploy
Next Steps
Section titled “Next Steps”- Add markdown parsing with a library like
marked - Implement post categories and tags
- Add RSS feed generation
- Implement search functionality
- Add social sharing buttons
Related Examples
Section titled “Related Examples”- SaaS Starter - User management and subscriptions
- API Backend - RESTful API design
- Real-time Chat - WebSocket communication