Skip to main content
RapidDev - Software Development Agency
how-to-build-v030-60 minutes

How to Build Simple CMS with V0

Build a lightweight content management system with V0 using Next.js and Supabase. You'll create a Markdown editor for writing posts, an admin dashboard for managing content, auto-generated URL slugs, ISR for fast public pages, and category management — all in about 30-60 minutes without any CMS platform fees.

What you'll build

  • Markdown content editor with shadcn/ui Textarea and live preview for writing blog posts
  • Admin dashboard with shadcn/ui Table for managing posts with draft/published status Badge indicators
  • Public blog pages using generateStaticParams and ISR for fast server-rendered content
  • Auto-generated URL-safe slugs from post titles with uniqueness validation
  • Category management with many-to-many relationships and Select dropdowns
  • On-demand ISR revalidation API route that refreshes published pages after editing
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read30-60 minutesV0 FreeApril 2026RapidDev Engineering Team
TL;DR

Build a lightweight content management system with V0 using Next.js and Supabase. You'll create a Markdown editor for writing posts, an admin dashboard for managing content, auto-generated URL slugs, ISR for fast public pages, and category management — all in about 30-60 minutes without any CMS platform fees.

What you're building

You want a simple way to publish content — blog posts, help articles, or landing pages — without paying for WordPress, Contentful, or other CMS platforms. A custom CMS gives you full control over your content structure, URL format, and presentation.

V0 makes this easy. You prompt it to create the admin editor, post listing, and public blog layout, and it generates the full Next.js implementation with shadcn/ui components. Supabase stores your content and handles auth for the admin panel. ISR (Incremental Static Regeneration) means your public pages load instantly.

The architecture uses app/blog/[slug]/page.tsx as a Server Component with generateStaticParams for ISR, app/admin/posts/ for the content management dashboard, Server Actions for create/update/delete/publish operations, and an API route for on-demand revalidation when content changes.

Final result

A complete CMS with a Markdown editor, admin dashboard, category management, auto-slugs, ISR-powered public pages, and on-demand revalidation.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase

Prerequisites

  • A V0 account (free tier works for this project)
  • A Supabase project (free tier works — connect via V0's Connect panel)
  • Content to publish (blog posts, articles, or pages)
  • Basic understanding of Markdown formatting

Build steps

1

Set up the content database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Prompt V0 to create the posts, categories, and junction tables for a content management system with draft/published workflow.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create a Supabase schema for a simple CMS:
3// 1. posts table: id (uuid PK), title (text NOT NULL), slug (text UNIQUE NOT NULL), content (text — stores Markdown), excerpt (text), cover_image_url (text), status (text DEFAULT 'draft' — 'draft', 'published', 'archived'), author_id (uuid FK), published_at (timestamptz nullable), created_at (timestamptz), updated_at (timestamptz)
4// 2. categories table: id (uuid PK), name (text), slug (text UNIQUE)
5// 3. post_categories junction: post_id (uuid FK), category_id (uuid FK), PRIMARY KEY(post_id, category_id)
6// Add RLS: public can read published posts, only authenticated admin can write.
7// Seed 3 categories: Technology, Business, Tutorial.
8// Generate the SQL migration.

Pro tip: Use V0's prompt queuing — queue the schema prompt first, then immediately queue prompts for the public blog layout and the admin editor. V0 builds them sequentially while you review each output.

Expected result: Three tables created in Supabase with RLS policies allowing public read of published posts and admin-only write access. Three categories seeded.

2

Build the public blog with ISR

Create the public-facing blog listing and individual post pages. The post page uses generateStaticParams for ISR, so pages are statically generated at build time and revalidated periodically.

app/blog/[slug]/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
3import { Badge } from '@/components/ui/badge'
4import Link from 'next/link'
5import { notFound } from 'next/navigation'
6
7export const revalidate = 3600
8
9export async function generateStaticParams() {
10 const supabase = await createClient()
11 const { data: posts } = await supabase
12 .from('posts')
13 .select('slug')
14 .eq('status', 'published')
15 return posts?.map((post) => ({ slug: post.slug })) ?? []
16}
17
18export default async function BlogPost({
19 params,
20}: {
21 params: Promise<{ slug: string }>
22}) {
23 const { slug } = await params
24 const supabase = await createClient()
25 const { data: post } = await supabase
26 .from('posts')
27 .select('*, post_categories(categories(name, slug))')
28 .eq('slug', slug)
29 .eq('status', 'published')
30 .single()
31
32 if (!post) notFound()
33
34 return (
35 <article className="max-w-3xl mx-auto p-6">
36 {post.cover_image_url && (
37 <img src={post.cover_image_url} alt={post.title} className="w-full rounded-lg mb-6" />
38 )}
39 <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
40 <div className="flex gap-2 mb-6">
41 {post.post_categories?.map((pc: any) => (
42 <Badge key={pc.categories.slug} variant="secondary">
43 {pc.categories.name}
44 </Badge>
45 ))}
46 </div>
47 <div className="prose prose-lg max-w-none">
48 {post.content}
49 </div>
50 </article>
51 )
52}

Expected result: Blog posts render as statically generated pages with a 1-hour revalidation. Each post shows the title, cover image, category Badges, and Markdown content.

3

Create the admin post editor with Markdown support

Build the admin editor page with a Textarea for Markdown content, Input fields for title and slug, Select for category and status, and save/publish Buttons. The slug auto-generates from the title.

components/post-editor.tsx
1'use client'
2
3import { useState } from 'react'
4import { Input } from '@/components/ui/input'
5import { Textarea } from '@/components/ui/textarea'
6import { Button } from '@/components/ui/button'
7import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
8import { Label } from '@/components/ui/label'
9import { savePost } from '@/app/actions/posts'
10
11function slugify(text: string) {
12 return text
13 .toLowerCase()
14 .replace(/[^a-z0-9]+/g, '-')
15 .replace(/(^-|-$)/g, '')
16}
17
18export function PostEditor({ post, categories }: { post?: any; categories: any[] }) {
19 const [title, setTitle] = useState(post?.title ?? '')
20 const [slug, setSlug] = useState(post?.slug ?? '')
21 const [content, setContent] = useState(post?.content ?? '')
22 const [status, setStatus] = useState(post?.status ?? 'draft')
23
24 return (
25 <form action={savePost} className="space-y-4 max-w-3xl">
26 <input type="hidden" name="id" value={post?.id ?? ''} />
27 <div className="space-y-2">
28 <Label htmlFor="title">Title</Label>
29 <Input
30 id="title"
31 name="title"
32 value={title}
33 onChange={(e) => {
34 setTitle(e.target.value)
35 if (!post) setSlug(slugify(e.target.value))
36 }}
37 placeholder="Post title"
38 required
39 />
40 </div>
41 <div className="space-y-2">
42 <Label htmlFor="slug">URL Slug</Label>
43 <Input
44 id="slug"
45 name="slug"
46 value={slug}
47 onChange={(e) => setSlug(e.target.value)}
48 placeholder="post-url-slug"
49 required
50 />
51 </div>
52 <div className="space-y-2">
53 <Label htmlFor="content">Content (Markdown)</Label>
54 <Textarea
55 id="content"
56 name="content"
57 value={content}
58 onChange={(e) => setContent(e.target.value)}
59 placeholder="Write your post in Markdown..."
60 rows={20}
61 required
62 />
63 </div>
64 <div className="space-y-2">
65 <Label>Status</Label>
66 <Select name="status" value={status} onValueChange={setStatus}>
67 <SelectTrigger>
68 <SelectValue />
69 </SelectTrigger>
70 <SelectContent>
71 <SelectItem value="draft">Draft</SelectItem>
72 <SelectItem value="published">Published</SelectItem>
73 <SelectItem value="archived">Archived</SelectItem>
74 </SelectContent>
75 </Select>
76 </div>
77 <input type="hidden" name="status" value={status} />
78 <div className="flex gap-2">
79 <Button type="submit">Save</Button>
80 <Button type="submit" name="action" value="publish" variant="default">
81 Save & Publish
82 </Button>
83 </div>
84 </form>
85 )
86}

Expected result: An editor form with title Input (auto-generates slug), Markdown Textarea, status Select dropdown, and Save/Publish Buttons.

4

Create Server Actions for post CRUD and slug validation

Build Server Actions for creating, updating, and publishing posts. The slug validation checks uniqueness against existing posts and appends a number suffix if a duplicate is found.

app/actions/posts.ts
1'use server'
2
3import { createClient } from '@/lib/supabase/server'
4import { revalidatePath, revalidateTag } from 'next/cache'
5import { redirect } from 'next/navigation'
6
7function slugify(text: string) {
8 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
9}
10
11async function ensureUniqueSlug(slug: string, excludeId?: string) {
12 const supabase = await createClient()
13 let candidate = slug
14 let suffix = 1
15
16 while (true) {
17 let query = supabase
18 .from('posts')
19 .select('id')
20 .eq('slug', candidate)
21 if (excludeId) query = query.neq('id', excludeId)
22 const { data } = await query.maybeSingle()
23 if (!data) return candidate
24 candidate = `${slug}-${suffix}`
25 suffix++
26 }
27}
28
29export async function savePost(formData: FormData) {
30 const supabase = await createClient()
31 const id = formData.get('id') as string
32 const title = formData.get('title') as string
33 const rawSlug = formData.get('slug') as string
34 const content = formData.get('content') as string
35 const action = formData.get('action') as string
36 let status = formData.get('status') as string
37
38 if (action === 'publish') status = 'published'
39
40 const slug = await ensureUniqueSlug(slugify(rawSlug), id || undefined)
41 const excerpt = content.slice(0, 200).replace(/[#*_`]/g, '')
42
43 const postData = {
44 title,
45 slug,
46 content,
47 excerpt,
48 status,
49 updated_at: new Date().toISOString(),
50 ...(status === 'published' ? { published_at: new Date().toISOString() } : {}),
51 }
52
53 if (id) {
54 await supabase.from('posts').update(postData).eq('id', id)
55 } else {
56 await supabase.from('posts').insert(postData)
57 }
58
59 if (status === 'published') {
60 await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate?slug=${slug}`, {
61 method: 'POST',
62 }).catch(() => {})
63 }
64
65 revalidatePath('/admin/posts')
66 redirect('/admin/posts')
67}

Pro tip: Set SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab (no NEXT_PUBLIC_ prefix) for admin mutations that bypass RLS. The admin Server Actions need write access that anon keys don't have.

Expected result: Saving a post validates the slug for uniqueness, creates or updates the post, triggers ISR revalidation for published posts, and redirects back to the admin list.

5

Build the admin dashboard with post list and status management

Create the admin posts listing page with a Table showing all posts, their status as colored Badges, and action buttons for editing, publishing, and deleting.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build an admin posts dashboard at app/admin/posts/page.tsx.
3// Requirements:
4// - Server Component that fetches all posts from Supabase ordered by updated_at desc
5// - Display in a shadcn/ui Table with columns: Title, Slug, Status, Categories, Updated, Actions
6// - Status column uses Badge: draft=gray, published=green, archived=yellow
7// - Actions column has DropdownMenu with Edit, Publish, Archive, and Delete options
8// - Delete uses AlertDialog for confirmation before deleting
9// - Add a "New Post" Button at the top that links to app/admin/posts/new
10// - Include a summary row showing total posts, published count, and draft count
11// - Add Tabs to filter between All, Published, and Drafts
12// - Use Card wrapper for the table section

Expected result: An admin dashboard with a Table of all posts, color-coded status Badges, action DropdownMenu, New Post button, and tab-based filtering between post statuses.

Complete code

app/blog/[slug]/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { Badge } from '@/components/ui/badge'
3import { notFound } from 'next/navigation'
4
5export const revalidate = 3600
6
7export async function generateStaticParams() {
8 const supabase = await createClient()
9 const { data: posts } = await supabase
10 .from('posts')
11 .select('slug')
12 .eq('status', 'published')
13 return posts?.map((post) => ({ slug: post.slug })) ?? []
14}
15
16export async function generateMetadata({
17 params,
18}: {
19 params: Promise<{ slug: string }>
20}) {
21 const { slug } = await params
22 const supabase = await createClient()
23 const { data: post } = await supabase
24 .from('posts')
25 .select('title, excerpt')
26 .eq('slug', slug)
27 .single()
28 return {
29 title: post?.title,
30 description: post?.excerpt,
31 }
32}
33
34export default async function BlogPost({
35 params,
36}: {
37 params: Promise<{ slug: string }>
38}) {
39 const { slug } = await params
40 const supabase = await createClient()
41 const { data: post } = await supabase
42 .from('posts')
43 .select('*, post_categories(categories(name, slug))')
44 .eq('slug', slug)
45 .eq('status', 'published')
46 .single()
47
48 if (!post) notFound()
49
50 return (
51 <article className="max-w-3xl mx-auto p-6">
52 <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
53 <div className="flex gap-2 mb-6">
54 {post.post_categories?.map((pc: any) => (
55 <Badge key={pc.categories.slug} variant="secondary">
56 {pc.categories.name}
57 </Badge>
58 ))}
59 <span className="text-muted-foreground text-sm">
60 {new Date(post.published_at).toLocaleDateString()}
61 </span>
62 </div>
63 <div className="prose prose-lg max-w-none">
64 {post.content}
65 </div>
66 </article>
67 )
68}

Customization ideas

Add rich text editor

Replace the Markdown Textarea with a Tiptap or Plate rich text editor for WYSIWYG editing. Prompt V0 to add @tiptap/react with a toolbar for bold, italic, headings, and image upload.

Add image upload for cover photos

Use Supabase Storage to let admins upload cover images directly. Add a file Input to the editor form and upload to a public bucket, storing the URL in cover_image_url.

Add scheduled publishing

Add a scheduled_at field to posts. A Vercel Cron Job checks every 5 minutes for posts with scheduled_at in the past and publishes them automatically.

Add SEO metadata editor

Extend the post editor with fields for meta_title, meta_description, and og_image. Use these in generateMetadata for optimized social sharing and search engine snippets.

Common pitfalls

Pitfall: Not setting up ISR revalidation, so published changes never appear on the public site

How to avoid: Create an API route at app/api/revalidate/route.ts that calls revalidatePath('/blog/' + slug). Call it from the savePost Server Action whenever status is 'published'.

Pitfall: Allowing duplicate slugs that break routing

How to avoid: Use the ensureUniqueSlug function that checks slug existence in Supabase and appends -1, -2, etc. if duplicates are found. Also add a UNIQUE constraint on posts.slug in the database.

Pitfall: Using the anon key for admin write operations

How to avoid: Use SUPABASE_SERVICE_ROLE_KEY (stored in Vars tab without NEXT_PUBLIC_ prefix) in Server Actions for admin mutations. This bypasses RLS. Protect admin routes with authentication middleware.

Best practices

  • Use generateStaticParams with ISR for public blog pages — they load instantly and revalidate automatically every hour
  • Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix for admin Server Actions that need to bypass RLS
  • Use V0's prompt queuing to build the public blog, admin editor, and category manager as three separate prompts in sequence
  • Generate URL-safe slugs automatically from titles and validate uniqueness before saving to prevent routing conflicts
  • Use Design Mode (Option+D) to visually adjust blog post typography, spacing, and Card layouts at zero credit cost
  • Add generateMetadata to blog post pages for SEO — dynamically set the page title and description from post content
  • Trigger on-demand ISR revalidation via revalidatePath when publishing or updating posts so changes appear immediately

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a simple CMS with Next.js App Router and Supabase. I need: 1) A posts table with draft/published status and Markdown content, 2) An admin editor with auto-slug generation, 3) Public blog pages with ISR using generateStaticParams, 4) On-demand revalidation when content changes. Help me design the schema with categories (many-to-many) and the Server Actions for CRUD operations.

Build Prompt

Create a Server Action that auto-generates a URL-safe slug from a post title and ensures uniqueness against existing posts in Supabase. The function should: 1) Convert the title to lowercase, replace non-alphanumeric characters with hyphens, 2) Query the posts table for existing slugs matching the candidate, 3) If duplicate found, append -1, -2, etc. until unique, 4) Return the unique slug. Also handle the case where we are updating an existing post (exclude its own ID from the uniqueness check).

Frequently asked questions

Do I need to know Markdown to use this CMS?

Basic Markdown is simple: use # for headings, ** for bold, * for italic, and - for bullet lists. You can also replace the Markdown Textarea with a rich text editor like Tiptap for WYSIWYG editing — just prompt V0 to add it.

How does ISR work for blog posts?

ISR (Incremental Static Regeneration) pre-renders blog pages at build time and caches them. When a cached page is requested after the revalidate period (3600 seconds = 1 hour), Next.js regenerates it in the background. On-demand revalidation via revalidatePath triggers immediate refresh when you publish changes.

What V0 plan do I need?

V0 Free tier works perfectly. This CMS uses basic Server Components, Server Actions, and shadcn/ui components. Supabase connection via Connect panel and Design Mode adjustments are both free.

Can I use this for pages beyond blog posts?

Yes. The posts table works for any content type — blog posts, help articles, landing pages, or documentation. Add a type field to distinguish between content types and create separate listing pages for each.

How do I deploy this CMS?

Click Share in V0, then Publish to Production. Blog pages deploy with ISR enabled automatically. The admin panel is protected by Supabase auth. For GitHub-based deployments, use the Git panel to connect and auto-create branches.

Can RapidDev help build a custom CMS?

Yes. RapidDev has built 600+ apps including content platforms with custom editors, workflow approvals, multi-language support, and headless CMS architectures. Book a free consultation to discuss your content management needs.

Can multiple admins edit content at the same time?

Yes, but without conflict resolution. Each admin sees the latest saved version. For collaborative editing, add an updated_at check in the Server Action that rejects saves if the post was modified since the editor loaded it, preventing accidental overwrites.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.