# How to Build Blog backend with V0

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

## TL;DR

Build a headless blog backend with V0 using Next.js and Supabase. You'll get a rich markdown editor, draft/publish workflow, ISR with on-demand revalidation, SEO metadata, and a comment moderation system — all in about 30-60 minutes without any local setup.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Basic familiarity with markdown syntax (optional but helpful)

## Step-by-step guide

### 1. Set up the project and blog database schema

Create a new V0 project and connect Supabase via the Connect panel. Prompt V0 to create the posts, categories, and comments tables with proper constraints for a blog with draft/publish workflow.

```
// Paste this prompt into V0's AI chat:
// Build a blog backend with Supabase. Create these tables:
// 1. posts: id (uuid PK), author_id (uuid FK to auth.users), title (text), slug (text unique), content (text), excerpt (text), cover_image_url (text), status (text CHECK in 'draft','published','archived'), published_at (timestamptz), meta_title (text), meta_description (text), created_at (timestamptz), updated_at (timestamptz)
// 2. categories: id (uuid PK), name (text), slug (text unique)
// 3. post_categories: post_id (uuid FK), category_id (uuid FK), PRIMARY KEY (post_id, category_id)
// 4. comments: id (uuid PK), post_id (uuid FK), author_name (text), content (text), is_approved (boolean default false), created_at (timestamptz)
// Add RLS: anyone can read published posts, only authors can edit their own posts, comments require approval to be visible.
// Generate the SQL migration.
```

> Pro tip: Use Design Mode (Option+D) after the blog listing page is generated to visually adjust card spacing, typography, and cover image aspect ratios without spending any credits.

**Expected result:** Supabase is connected with all blog tables created, RLS policies applied, and the schema ready for content.

### 2. Build the public blog listing and post pages with ISR

Create the blog listing page that shows published posts as Cards, and individual post pages that are statically generated with ISR. Each post page uses generateMetadata for SEO and generateStaticParams for static generation.

```
import { createClient } from '@supabase/supabase-js'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export const revalidate = 3600

export async function generateStaticParams() {
  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 }>
}): Promise<Metadata> {
  const { slug } = await params
  const { data: post } = await supabase
    .from('posts')
    .select('meta_title, meta_description, cover_image_url')
    .eq('slug', slug)
    .eq('status', 'published')
    .single()

  if (!post) return {}
  return {
    title: post.meta_title,
    description: post.meta_description,
    openGraph: { images: post.cover_image_url ? [post.cover_image_url] : [] },
  }
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const { data: post } = await supabase
    .from('posts')
    .select('*, comments(id, author_name, content, created_at)')
    .eq('slug', slug)
    .eq('status', 'published')
    .single()

  if (!post) notFound()

  return (
    <article className="mx-auto max-w-2xl py-8">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="prose prose-lg" dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}
```

**Expected result:** Blog posts are statically generated at build time and revalidated every hour. Each post has proper SEO metadata and Open Graph images.

### 3. Create the writing dashboard with post management

Build the author dashboard with a table of all posts, create/edit forms with a markdown editor, and a draft/publish toggle. The publish action triggers ISR revalidation so the public page updates instantly.

```
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath, revalidateTag } from 'next/cache'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function publishPost(postId: string) {
  const { data: post, error } = await supabase
    .from('posts')
    .update({
      status: 'published',
      published_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    })
    .eq('id', postId)
    .select('slug')
    .single()

  if (error) throw new Error(error.message)

  revalidatePath('/blog')
  revalidatePath(`/blog/${post.slug}`)
}

export async function savePost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const excerpt = formData.get('excerpt') as string
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
  const metaTitle = formData.get('meta_title') as string || title
  const metaDescription = formData.get('meta_description') as string || excerpt

  const { error } = await supabase.from('posts').insert({
    title,
    slug,
    content,
    excerpt,
    meta_title: metaTitle,
    meta_description: metaDescription,
    status: 'draft',
  })

  if (error) throw new Error(error.message)
  revalidatePath('/dashboard/posts')
}
```

> Pro tip: Use revalidatePath('/blog') in the publish action to regenerate the listing page, and revalidatePath('/blog/' + slug) to regenerate the specific post page. This gives you instant updates without full rebuilds.

**Expected result:** The dashboard shows all posts in a table with status badges. Publishing a post triggers instant ISR revalidation of both the listing and post pages.

### 4. Add cover image uploads with Supabase Storage

Enable image uploads for blog post cover images using Supabase Storage. Create a public bucket and generate an upload component that stores images and returns the public URL to save in the post record.

```
// Paste this prompt into V0's AI chat:
// Build a cover image upload component for the blog post editor.
// Requirements:
// - Drag-and-drop area with preview using a 'use client' component
// - Upload to Supabase Storage 'covers' public bucket
// - Accept jpg, png, webp up to 5MB
// - Show upload progress and preview after upload
// - Return the public URL to store in posts.cover_image_url
// - Use shadcn/ui Card for the drop zone, Button for manual select
// - Add a delete button to remove the current cover image
// - Use createBrowserClient for the upload (client-side operation)
```

**Expected result:** The post editor includes a drag-and-drop cover image uploader. Images are stored in Supabase Storage and the public URL is saved with the post.

## Complete code example

File: `app/actions/posts.ts`

```typescript
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function savePost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const excerpt = formData.get('excerpt') as string
  const coverImageUrl = formData.get('cover_image_url') as string
  const metaTitle = (formData.get('meta_title') as string) || title
  const metaDescription = (formData.get('meta_description') as string) || excerpt

  const slug = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')

  const { error } = await supabase.from('posts').insert({
    title,
    slug,
    content,
    excerpt,
    cover_image_url: coverImageUrl || null,
    meta_title: metaTitle,
    meta_description: metaDescription,
    status: 'draft',
  })

  if (error) throw new Error(error.message)
  revalidatePath('/dashboard/posts')
  redirect('/dashboard/posts')
}

export async function publishPost(postId: string) {
  const { data: post, error } = await supabase
    .from('posts')
    .update({
      status: 'published',
      published_at: new Date().toISOString(),
    })
    .eq('id', postId)
    .select('slug')
    .single()

  if (error) throw new Error(error.message)
  revalidatePath('/blog')
  revalidatePath(`/blog/${post.slug}`)
}

export async function unpublishPost(postId: string) {
  await supabase
    .from('posts')
    .update({ status: 'draft' })
    .eq('id', postId)
  revalidatePath('/blog')
  revalidatePath('/dashboard/posts')
}
```

## Common mistakes

- **Forgetting to call revalidatePath after publishing a post** — ISR caches pages at build time with a revalidation interval. Without on-demand revalidation, new posts will not appear until the cache expires (up to the revalidate interval). Fix: Call revalidatePath('/blog') and revalidatePath('/blog/' + slug) in the publish Server Action. This instantly regenerates both the listing and the individual post page.
- **Not adding generateMetadata for SEO on post pages** — Without dynamic metadata, all blog posts share the same title and description in search results, destroying your SEO value. Fix: Export a generateMetadata async function in each dynamic page that fetches the post's meta_title and meta_description from Supabase and returns them as Metadata.
- **Using dangerouslySetInnerHTML without sanitization** — If blog content can contain arbitrary HTML (from markdown conversion or user input), unsanitized HTML opens the door to XSS attacks. Fix: Use a library like react-markdown for rendering markdown safely, or sanitize HTML with DOMPurify before using dangerouslySetInnerHTML.

## Best practices

- Use ISR with revalidatePath for instant content updates without full rebuilds — set a fallback revalidate interval of 3600 seconds
- Use generateStaticParams for blog posts to pre-render all published posts at build time for maximum SEO performance
- Use generateMetadata to create unique title, description, and Open Graph tags for each blog post
- Store cover images in a Supabase Storage public bucket for fast CDN delivery
- Use Server Components for all public-facing blog pages to keep database queries server-side
- Use Design Mode (Option+D) to visually refine the blog card layout, typography, and cover image styling without spending credits
- Add RLS policies so only post authors can edit their own content and public users can only read published posts
- Auto-generate URL slugs from post titles to ensure clean, SEO-friendly URLs

## Frequently asked questions

### What is ISR and why should I use it for a blog?

ISR (Incremental Static Regeneration) pre-renders pages at build time and serves them from the CDN edge. When content changes, revalidatePath regenerates only the affected pages. This gives your blog the speed of static sites with the flexibility of dynamic content.

### Can I use the V0 free plan to build a blog backend?

Yes. A basic blog requires only a few pages and Server Actions, which fits within the V0 free plan's credit allocation. The blog is one of the simplest projects to build with V0.

### Should I use markdown or a rich text editor?

Markdown is simpler and recommended for technical blogs. For non-technical content creators, consider adding Tiptap or Novel editor for WYSIWYG editing. V0 can generate either approach from a prompt.

### How do I handle SEO for individual blog posts?

Export a generateMetadata function in your app/blog/[slug]/page.tsx that fetches the post's meta_title and meta_description from Supabase. Next.js automatically generates the correct title tag, meta description, and Open Graph tags.

### How do I deploy the blog?

Click Share then Publish to Production in V0 for instant Vercel deployment. Blog pages are statically generated on first request and cached at the edge. Publishing new content triggers on-demand revalidation.

### Can I add multiple authors to the blog?

Yes. The posts table includes an author_id foreign key. Add a join to the profiles table when fetching posts to display author names and avatars. RLS policies ensure authors can only edit their own posts.

### Can RapidDev help build a custom blog or CMS?

Yes. RapidDev has built 600+ apps including custom content platforms with multi-author workflows, SEO optimization, and newsletter integration. Book a free consultation to discuss your content needs.

---

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