Skip to main content

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![Image](${url})\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
}))
];
}