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.
What you're building
A blog is one of the most effective marketing tools for any business. But most blog platforms are either too simple (no SEO control) or too complex (full CMS overhead). What founders really need is a fast, SEO-optimized blog with a simple writing experience.
V0 generates the entire blog architecture from prompts — the listing page, individual post pages with ISR, the writing dashboard with markdown, and the comment system. Supabase via the Connect panel provides the database for posts and comments, plus Storage for cover images.
The architecture uses Next.js ISR (Incremental Static Regeneration) so blog pages are statically generated for maximum speed and SEO performance. When a post is published, a Server Action calls revalidatePath to regenerate the page instantly. Server Components handle all data fetching, and the markdown editor is a Client Component.
Final result
A production-ready blog with a writing dashboard, markdown editing, draft/publish workflow, ISR-powered public pages with SEO metadata, cover image uploads, and a comment system with moderation.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a blog backend with Supabase. Create these tables:3// 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)4// 2. categories: id (uuid PK), name (text), slug (text unique)5// 3. post_categories: post_id (uuid FK), category_id (uuid FK), PRIMARY KEY (post_id, category_id)6// 4. comments: id (uuid PK), post_id (uuid FK), author_name (text), content (text), is_approved (boolean default false), created_at (timestamptz)7// Add RLS: anyone can read published posts, only authors can edit their own posts, comments require approval to be visible.8// 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.
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.
1import { createClient } from '@supabase/supabase-js'2import { notFound } from 'next/navigation'3import type { Metadata } from 'next'45const supabase = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910export const revalidate = 36001112export async function generateStaticParams() {13 const { data: posts } = await supabase14 .from('posts')15 .select('slug')16 .eq('status', 'published')17 return posts?.map((post) => ({ slug: post.slug })) ?? []18}1920export async function generateMetadata({21 params,22}: {23 params: Promise<{ slug: string }>24}): Promise<Metadata> {25 const { slug } = await params26 const { data: post } = await supabase27 .from('posts')28 .select('meta_title, meta_description, cover_image_url')29 .eq('slug', slug)30 .eq('status', 'published')31 .single()3233 if (!post) return {}34 return {35 title: post.meta_title,36 description: post.meta_description,37 openGraph: { images: post.cover_image_url ? [post.cover_image_url] : [] },38 }39}4041export default async function PostPage({42 params,43}: {44 params: Promise<{ slug: string }>45}) {46 const { slug } = await params47 const { data: post } = await supabase48 .from('posts')49 .select('*, comments(id, author_name, content, created_at)')50 .eq('slug', slug)51 .eq('status', 'published')52 .single()5354 if (!post) notFound()5556 return (57 <article className="mx-auto max-w-2xl py-8">58 <h1 className="text-4xl font-bold mb-4">{post.title}</h1>59 <div className="prose prose-lg" dangerouslySetInnerHTML={{ __html: post.content }} />60 </article>61 )62}Expected result: Blog posts are statically generated at build time and revalidated every hour. Each post has proper SEO metadata and Open Graph images.
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.
1'use server'23import { createClient } from '@supabase/supabase-js'4import { revalidatePath, revalidateTag } from 'next/cache'56const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function publishPost(postId: string) {12 const { data: post, error } = await supabase13 .from('posts')14 .update({15 status: 'published',16 published_at: new Date().toISOString(),17 updated_at: new Date().toISOString(),18 })19 .eq('id', postId)20 .select('slug')21 .single()2223 if (error) throw new Error(error.message)2425 revalidatePath('/blog')26 revalidatePath(`/blog/${post.slug}`)27}2829export async function savePost(formData: FormData) {30 const title = formData.get('title') as string31 const content = formData.get('content') as string32 const excerpt = formData.get('excerpt') as string33 const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')34 const metaTitle = formData.get('meta_title') as string || title35 const metaDescription = formData.get('meta_description') as string || excerpt3637 const { error } = await supabase.from('posts').insert({38 title,39 slug,40 content,41 excerpt,42 meta_title: metaTitle,43 meta_description: metaDescription,44 status: 'draft',45 })4647 if (error) throw new Error(error.message)48 revalidatePath('/dashboard/posts')49}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.
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.
1// Paste this prompt into V0's AI chat:2// Build a cover image upload component for the blog post editor.3// Requirements:4// - Drag-and-drop area with preview using a 'use client' component5// - Upload to Supabase Storage 'covers' public bucket6// - Accept jpg, png, webp up to 5MB7// - Show upload progress and preview after upload8// - Return the public URL to store in posts.cover_image_url9// - Use shadcn/ui Card for the drop zone, Button for manual select10// - Add a delete button to remove the current cover image11// - 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
1'use server'23import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'5import { redirect } from 'next/navigation'67const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function savePost(formData: FormData) {13 const title = formData.get('title') as string14 const content = formData.get('content') as string15 const excerpt = formData.get('excerpt') as string16 const coverImageUrl = formData.get('cover_image_url') as string17 const metaTitle = (formData.get('meta_title') as string) || title18 const metaDescription = (formData.get('meta_description') as string) || excerpt1920 const slug = title21 .toLowerCase()22 .replace(/[^a-z0-9]+/g, '-')23 .replace(/(^-|-$)/g, '')2425 const { error } = await supabase.from('posts').insert({26 title,27 slug,28 content,29 excerpt,30 cover_image_url: coverImageUrl || null,31 meta_title: metaTitle,32 meta_description: metaDescription,33 status: 'draft',34 })3536 if (error) throw new Error(error.message)37 revalidatePath('/dashboard/posts')38 redirect('/dashboard/posts')39}4041export async function publishPost(postId: string) {42 const { data: post, error } = await supabase43 .from('posts')44 .update({45 status: 'published',46 published_at: new Date().toISOString(),47 })48 .eq('id', postId)49 .select('slug')50 .single()5152 if (error) throw new Error(error.message)53 revalidatePath('/blog')54 revalidatePath(`/blog/${post.slug}`)55}5657export async function unpublishPost(postId: string) {58 await supabase59 .from('posts')60 .update({ status: 'draft' })61 .eq('id', postId)62 revalidatePath('/blog')63 revalidatePath('/dashboard/posts')64}Customization ideas
Add a rich text editor
Replace the markdown Textarea with Tiptap or Novel editor for a WYSIWYG editing experience with formatting toolbar, image embedding, and code blocks.
Add RSS feed generation
Create an API route at app/feed.xml/route.ts that generates an RSS 2.0 feed from published posts using Server Components and returns XML with the correct content type.
Add reading time estimation
Calculate reading time from the word count of the post content (average 200 words per minute) and display it as a Badge on both the listing and post pages.
Add newsletter subscription
Add an email capture form at the bottom of blog posts that subscribes readers via the Resend API, stored in a subscribers table in Supabase.
Common pitfalls
Pitfall: Forgetting to call revalidatePath after publishing a post
How to avoid: Call revalidatePath('/blog') and revalidatePath('/blog/' + slug) in the publish Server Action. This instantly regenerates both the listing and the individual post page.
Pitfall: Not adding generateMetadata for SEO on post pages
How to avoid: 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.
Pitfall: Using dangerouslySetInnerHTML without sanitization
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a blog backend with Next.js App Router and Supabase. I need ISR with on-demand revalidation, a markdown editor, draft/publish workflow, SEO metadata with generateMetadata, and a comment system. Help me design the ISR strategy with revalidatePath for instant content updates.
Build an ISR-powered blog listing page that fetches published posts from Supabase, displays them in a responsive Card grid with cover images, excerpts, and category Badges. Set revalidate to 3600 as a fallback. When the publish Server Action fires, it calls revalidatePath('/blog') to regenerate the listing instantly. Each Card links to the individual post page at /blog/[slug].
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation