Blog CMS Example
Build a content management system for blogs and websites.
Overview
This example creates a full-featured CMS with:
- Rich text editor
- Media library
- Categories & tags
- SEO optimization
- Comments
- RSS feed
Create the Project
npx autodeploybase init my-blog \
--framework next \
--archetype cms \
--database postgresql \
--plugins file-storage,email
cd my-blog
Features
Post Editor
app/admin/posts/[id]/page.tsx
import { PostEditor } from '@/components/admin/PostEditor';
import { prisma } from '@/lib/db';
export default async function EditPostPage({
params
}: {
params: { id: string }
}) {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: { categories: true, tags: true }
});
return <PostEditor post={post} />;
}
Rich Text Editor
components/admin/PostEditor.tsx
'use client';
import { useState } from 'react';
import { Editor } from '@/components/ui/Editor';
import { ImageUploader } from '@/components/admin/ImageUploader';
export function PostEditor({ post }) {
const [content, setContent] = useState(post?.content || '');
const [title, setTitle] = useState(post?.title || '');
async function handleSave() {
await fetch(`/api/admin/posts/${post?.id || ''}`, {
method: post ? 'PUT' : 'POST',
body: JSON.stringify({ title, content })
});
}
return (
<div className="post-editor">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
className="title-input"
/>
<Editor
value={content}
onChange={setContent}
placeholder="Write your post..."
/>
<div className="editor-toolbar">
<ImageUploader onUpload={(url) => {
setContent(content + `\n\n`);
}} />
<button onClick={handleSave}>Save</button>
</div>
</div>
);
}
Media Library
app/admin/media/page.tsx
import { MediaGrid } from '@/components/admin/MediaGrid';
import { UploadButton } from '@/components/admin/UploadButton';
import { prisma } from '@/lib/db';
export default async function MediaPage() {
const media = await prisma.media.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<div>
<div className="flex justify-between">
<h1>Media Library</h1>
<UploadButton />
</div>
<MediaGrid items={media} />
</div>
);
}
Blog Feed
app/blog/page.tsx
import { PostCard } from '@/components/blog/PostCard';
import { prisma } from '@/lib/db';
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { status: 'PUBLISHED' },
include: { author: true, categories: true },
orderBy: { publishedAt: 'desc' }
});
return (
<div className="blog-page">
<h1>Blog</h1>
<div className="posts-grid">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
Post Page with SEO
app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/db';
import { Comments } from '@/components/blog/Comments';
export async function generateMetadata({ params }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
});
return {
title: post?.title,
description: post?.excerpt,
openGraph: {
title: post?.title,
description: post?.excerpt,
images: [post?.featuredImage]
}
};
}
export default async function PostPage({ params }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug, status: 'PUBLISHED' },
include: { author: true, categories: true }
});
if (!post) notFound();
return (
<article className="post">
<h1>{post.title}</h1>
<div className="post-meta">
<span>By {post.author.name}</span>
<span>{post.publishedAt.toLocaleDateString()}</span>
</div>
<div
className="post-content"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<Comments postId={post.id} />
</article>
);
}
Database Schema
prisma/schema.prisma
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
featuredImage String?
status PostStatus @default(DRAFT)
authorId String
author User @relation(fields: [authorId], references: [id])
categories Category[]
tags Tag[]
comments Comment[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
name String
slug String @unique
posts Post[]
}
model Tag {
id String @id @default(cuid())
name String
slug String @unique
posts Post[]
}
model Comment {
id String @id @default(cuid())
content String
postId String
post Post @relation(fields: [postId], references: [id])
authorId String?
author User? @relation(fields: [authorId], references: [id])
guestName String?
guestEmail String?
approved Boolean @default(false)
createdAt DateTime @default(now())
}
model Media {
id String @id @default(cuid())
filename String
url String
contentType String
size Int
alt String?
createdAt DateTime @default(now())
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
RSS Feed
app/feed.xml/route.ts
import { prisma } from '@/lib/db';
export async function GET() {
const posts = await prisma.post.findMany({
where: { status: 'PUBLISHED' },
orderBy: { publishedAt: 'desc' },
take: 20
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<link>${process.env.NEXT_PUBLIC_APP_URL}</link>
<description>My awesome blog</description>
${posts.map(post => `
<item>
<title>${post.title}</title>
<link>${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}</link>
<description>${post.excerpt}</description>
<pubDate>${post.publishedAt?.toUTCString()}</pubDate>
</item>
`).join('')}
</channel>
</rss>`;
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' }
});
}
Sitemap
app/sitemap.ts
import { prisma } from '@/lib/db';
export default async function sitemap() {
const posts = await prisma.post.findMany({
where: { status: 'PUBLISHED' },
select: { slug: true, updatedAt: true }
});
return [
{ url: process.env.NEXT_PUBLIC_APP_URL, lastModified: new Date() },
{ url: `${process.env.NEXT_PUBLIC_APP_URL}/blog`, lastModified: new Date() },
...posts.map(post => ({
url: `${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}`,
lastModified: post.updatedAt
}))
];
}