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
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
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.
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.
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.
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'67export const revalidate = 360089export async function generateStaticParams() {10 const supabase = await createClient()11 const { data: posts } = await supabase12 .from('posts')13 .select('slug')14 .eq('status', 'published')15 return posts?.map((post) => ({ slug: post.slug })) ?? []16}1718export default async function BlogPost({19 params,20}: {21 params: Promise<{ slug: string }>22}) {23 const { slug } = await params24 const supabase = await createClient()25 const { data: post } = await supabase26 .from('posts')27 .select('*, post_categories(categories(name, slug))')28 .eq('slug', slug)29 .eq('status', 'published')30 .single()3132 if (!post) notFound()3334 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.
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.
1'use client'23import { 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'1011function slugify(text: string) {12 return text13 .toLowerCase()14 .replace(/[^a-z0-9]+/g, '-')15 .replace(/(^-|-$)/g, '')16}1718export 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')2324 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 <Input30 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 required39 />40 </div>41 <div className="space-y-2">42 <Label htmlFor="slug">URL Slug</Label>43 <Input44 id="slug"45 name="slug"46 value={slug}47 onChange={(e) => setSlug(e.target.value)}48 placeholder="post-url-slug"49 required50 />51 </div>52 <div className="space-y-2">53 <Label htmlFor="content">Content (Markdown)</Label>54 <Textarea55 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 required62 />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 & Publish82 </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.
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.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath, revalidateTag } from 'next/cache'5import { redirect } from 'next/navigation'67function slugify(text: string) {8 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')9}1011async function ensureUniqueSlug(slug: string, excludeId?: string) {12 const supabase = await createClient()13 let candidate = slug14 let suffix = 11516 while (true) {17 let query = supabase18 .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 candidate24 candidate = `${slug}-${suffix}`25 suffix++26 }27}2829export async function savePost(formData: FormData) {30 const supabase = await createClient()31 const id = formData.get('id') as string32 const title = formData.get('title') as string33 const rawSlug = formData.get('slug') as string34 const content = formData.get('content') as string35 const action = formData.get('action') as string36 let status = formData.get('status') as string3738 if (action === 'publish') status = 'published'3940 const slug = await ensureUniqueSlug(slugify(rawSlug), id || undefined)41 const excerpt = content.slice(0, 200).replace(/[#*_`]/g, '')4243 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 }5253 if (id) {54 await supabase.from('posts').update(postData).eq('id', id)55 } else {56 await supabase.from('posts').insert(postData)57 }5859 if (status === 'published') {60 await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate?slug=${slug}`, {61 method: 'POST',62 }).catch(() => {})63 }6465 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.
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.
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 desc5// - Display in a shadcn/ui Table with columns: Title, Slug, Status, Categories, Updated, Actions6// - Status column uses Badge: draft=gray, published=green, archived=yellow7// - Actions column has DropdownMenu with Edit, Publish, Archive, and Delete options8// - Delete uses AlertDialog for confirmation before deleting9// - Add a "New Post" Button at the top that links to app/admin/posts/new10// - Include a summary row showing total posts, published count, and draft count11// - Add Tabs to filter between All, Published, and Drafts12// - Use Card wrapper for the table sectionExpected 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
1import { createClient } from '@/lib/supabase/server'2import { Badge } from '@/components/ui/badge'3import { notFound } from 'next/navigation'45export const revalidate = 360067export async function generateStaticParams() {8 const supabase = await createClient()9 const { data: posts } = await supabase10 .from('posts')11 .select('slug')12 .eq('status', 'published')13 return posts?.map((post) => ({ slug: post.slug })) ?? []14}1516export async function generateMetadata({17 params,18}: {19 params: Promise<{ slug: string }>20}) {21 const { slug } = await params22 const supabase = await createClient()23 const { data: post } = await supabase24 .from('posts')25 .select('title, excerpt')26 .eq('slug', slug)27 .single()28 return {29 title: post?.title,30 description: post?.excerpt,31 }32}3334export default async function BlogPost({35 params,36}: {37 params: Promise<{ slug: string }>38}) {39 const { slug } = await params40 const supabase = await createClient()41 const { data: post } = await supabase42 .from('posts')43 .select('*, post_categories(categories(name, slug))')44 .eq('slug', slug)45 .eq('status', 'published')46 .single()4748 if (!post) notFound()4950 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation