# How to Build Simple CMS with V0

- Tool: v0
- Difficulty: Beginner
- Compatibility: V0 Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a simple CMS:
// 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)
// 2. categories table: id (uuid PK), name (text), slug (text UNIQUE)
// 3. post_categories junction: post_id (uuid FK), category_id (uuid FK), PRIMARY KEY(post_id, category_id)
// Add RLS: public can read published posts, only authenticated admin can write.
// Seed 3 categories: Technology, Business, Tutorial.
// 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.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { notFound } from 'next/navigation'

export const revalidate = 3600

export async function generateStaticParams() {
  const supabase = await createClient()
  const { data: posts } = await supabase
    .from('posts')
    .select('slug')
    .eq('status', 'published')
  return posts?.map((post) => ({ slug: post.slug })) ?? []
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const supabase = await createClient()
  const { data: post } = await supabase
    .from('posts')
    .select('*, post_categories(categories(name, slug))')
    .eq('slug', slug)
    .eq('status', 'published')
    .single()

  if (!post) notFound()

  return (
    <article className="max-w-3xl mx-auto p-6">
      {post.cover_image_url && (
        <img src={post.cover_image_url} alt={post.title} className="w-full rounded-lg mb-6" />
      )}
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="flex gap-2 mb-6">
        {post.post_categories?.map((pc: any) => (
          <Badge key={pc.categories.slug} variant="secondary">
            {pc.categories.name}
          </Badge>
        ))}
      </div>
      <div className="prose prose-lg max-w-none">
        {post.content}
      </div>
    </article>
  )
}
```

**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.

```
'use client'

import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { savePost } from '@/app/actions/posts'

function slugify(text: string) {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
}

export function PostEditor({ post, categories }: { post?: any; categories: any[] }) {
  const [title, setTitle] = useState(post?.title ?? '')
  const [slug, setSlug] = useState(post?.slug ?? '')
  const [content, setContent] = useState(post?.content ?? '')
  const [status, setStatus] = useState(post?.status ?? 'draft')

  return (
    <form action={savePost} className="space-y-4 max-w-3xl">
      <input type="hidden" name="id" value={post?.id ?? ''} />
      <div className="space-y-2">
        <Label htmlFor="title">Title</Label>
        <Input
          id="title"
          name="title"
          value={title}
          onChange={(e) => {
            setTitle(e.target.value)
            if (!post) setSlug(slugify(e.target.value))
          }}
          placeholder="Post title"
          required
        />
      </div>
      <div className="space-y-2">
        <Label htmlFor="slug">URL Slug</Label>
        <Input
          id="slug"
          name="slug"
          value={slug}
          onChange={(e) => setSlug(e.target.value)}
          placeholder="post-url-slug"
          required
        />
      </div>
      <div className="space-y-2">
        <Label htmlFor="content">Content (Markdown)</Label>
        <Textarea
          id="content"
          name="content"
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Write your post in Markdown..."
          rows={20}
          required
        />
      </div>
      <div className="space-y-2">
        <Label>Status</Label>
        <Select name="status" value={status} onValueChange={setStatus}>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="draft">Draft</SelectItem>
            <SelectItem value="published">Published</SelectItem>
            <SelectItem value="archived">Archived</SelectItem>
          </SelectContent>
        </Select>
      </div>
      <input type="hidden" name="status" value={status} />
      <div className="flex gap-2">
        <Button type="submit">Save</Button>
        <Button type="submit" name="action" value="publish" variant="default">
          Save & Publish
        </Button>
      </div>
    </form>
  )
}
```

**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.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

function slugify(text: string) {
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}

async function ensureUniqueSlug(slug: string, excludeId?: string) {
  const supabase = await createClient()
  let candidate = slug
  let suffix = 1

  while (true) {
    let query = supabase
      .from('posts')
      .select('id')
      .eq('slug', candidate)
    if (excludeId) query = query.neq('id', excludeId)
    const { data } = await query.maybeSingle()
    if (!data) return candidate
    candidate = `${slug}-${suffix}`
    suffix++
  }
}

export async function savePost(formData: FormData) {
  const supabase = await createClient()
  const id = formData.get('id') as string
  const title = formData.get('title') as string
  const rawSlug = formData.get('slug') as string
  const content = formData.get('content') as string
  const action = formData.get('action') as string
  let status = formData.get('status') as string

  if (action === 'publish') status = 'published'

  const slug = await ensureUniqueSlug(slugify(rawSlug), id || undefined)
  const excerpt = content.slice(0, 200).replace(/[#*_`]/g, '')

  const postData = {
    title,
    slug,
    content,
    excerpt,
    status,
    updated_at: new Date().toISOString(),
    ...(status === 'published' ? { published_at: new Date().toISOString() } : {}),
  }

  if (id) {
    await supabase.from('posts').update(postData).eq('id', id)
  } else {
    await supabase.from('posts').insert(postData)
  }

  if (status === 'published') {
    await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate?slug=${slug}`, {
      method: 'POST',
    }).catch(() => {})
  }

  revalidatePath('/admin/posts')
  redirect('/admin/posts')
}
```

> 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.

```
// Paste this prompt into V0's AI chat:
// Build an admin posts dashboard at app/admin/posts/page.tsx.
// Requirements:
// - Server Component that fetches all posts from Supabase ordered by updated_at desc
// - Display in a shadcn/ui Table with columns: Title, Slug, Status, Categories, Updated, Actions
// - Status column uses Badge: draft=gray, published=green, archived=yellow
// - Actions column has DropdownMenu with Edit, Publish, Archive, and Delete options
// - Delete uses AlertDialog for confirmation before deleting
// - Add a "New Post" Button at the top that links to app/admin/posts/new
// - Include a summary row showing total posts, published count, and draft count
// - Add Tabs to filter between All, Published, and Drafts
// - 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 example

File: `app/blog/[slug]/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import { Badge } from '@/components/ui/badge'
import { notFound } from 'next/navigation'

export const revalidate = 3600

export async function generateStaticParams() {
  const supabase = await createClient()
  const { data: posts } = await supabase
    .from('posts')
    .select('slug')
    .eq('status', 'published')
  return posts?.map((post) => ({ slug: post.slug })) ?? []
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const supabase = await createClient()
  const { data: post } = await supabase
    .from('posts')
    .select('title, excerpt')
    .eq('slug', slug)
    .single()
  return {
    title: post?.title,
    description: post?.excerpt,
  }
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const supabase = await createClient()
  const { data: post } = await supabase
    .from('posts')
    .select('*, post_categories(categories(name, slug))')
    .eq('slug', slug)
    .eq('status', 'published')
    .single()

  if (!post) notFound()

  return (
    <article className="max-w-3xl mx-auto p-6">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="flex gap-2 mb-6">
        {post.post_categories?.map((pc: any) => (
          <Badge key={pc.categories.slug} variant="secondary">
            {pc.categories.name}
          </Badge>
        ))}
        <span className="text-muted-foreground text-sm">
          {new Date(post.published_at).toLocaleDateString()}
        </span>
      </div>
      <div className="prose prose-lg max-w-none">
        {post.content}
      </div>
    </article>
  )
}
```

## Common mistakes

- **Not setting up ISR revalidation, so published changes never appear on the public site** — With generateStaticParams and revalidate, pages are cached. Without on-demand revalidation, edits only appear after the revalidate interval (e.g., 1 hour) expires. Fix: 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'.
- **Allowing duplicate slugs that break routing** — If two posts have the same slug, the blog/[slug] route returns the wrong post or errors. Users who bookmark a URL get unexpected content. Fix: 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.
- **Using the anon key for admin write operations** — RLS policies restrict the anon key to read-only access on published posts. Admin actions like creating and updating posts will silently fail or return permission errors. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/simple-cms
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/simple-cms
