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 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> );}Homepage
Section titled “Homepage”// app/page.tsximport 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> );}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 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> );}Create Post Page
Section titled “Create Post Page”// app/posts/new/page.tsximport 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> );}Posts API Route
Section titled “Posts API Route”// app/api/posts/route.tsimport 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}` }, });}Comments API Route
Section titled “Comments API Route”// app/api/posts/[slug]/comments/route.tsimport 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}` }, });}Running the Example
Section titled “Running the Example”-
Clone or create the project:
Terminal window pnpm dlx @cloudwerk/create-app 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