Build a real-time collaborative document editor with V0 using Next.js, Supabase Realtime for presence and synchronization, and version history tracking. You'll create a Google Docs-style workspace with live cursors, inline comments, and document sharing — all in about 2-4 hours without touching a terminal.
What you're building
Collaborative document editing has become essential for remote teams. Whether you need a shared knowledge base, a team wiki, or a client-facing document portal, real-time collaboration eliminates the back-and-forth of email attachments and version confusion.
V0 makes building this dramatically faster by generating the editor layout, real-time sync layer, and version history UI from natural language prompts. Use prompt queuing to build each layer sequentially — queue up to 10 prompts while V0 generates. Supabase Realtime Broadcast channels handle presence and document synchronization without any additional infrastructure.
The architecture uses Next.js App Router with a client-side editor component (for interactivity), Supabase for document storage and version snapshots, Realtime Broadcast for low-latency cursor and content synchronization, Server Actions for document CRUD and collaborator management, and shadcn/ui for the editor toolbar and collaboration panels.
Final result
A collaborative document editor with real-time presence indicators, live cursor tracking, version history with restore, inline comments, and role-based sharing controls.
Tech stack
Prerequisites
- A V0 account (Premium plan recommended for the multi-component editor)
- A Supabase project with Realtime enabled (free tier works — enable Realtime in Dashboard)
- Basic understanding of real-time applications (WebSockets concepts)
- Familiarity with rich text editing concepts (bold, italic, headings)
Build steps
Set up the project and database schema for documents
Open V0 and create a new project. Use the Connect panel to add Supabase. Then prompt V0 to create the document storage schema with versioning and collaboration tables.
1// Paste this prompt into V0's AI chat:2// Build a collaborative document editor. Create a Supabase schema with:3// 1. documents: id (uuid PK), title (text), content (jsonb), owner_id (uuid FK to auth.users), workspace_id (uuid), is_public (boolean default false), created_at (timestamptz), updated_at (timestamptz)4// 2. document_versions: id (uuid PK), document_id (uuid FK to documents), content (jsonb), version_number (int), created_by (uuid FK to auth.users), created_at (timestamptz)5// 3. collaborators: id (uuid PK), document_id (uuid FK to documents), user_id (uuid FK to auth.users), role (text check in 'editor','viewer','commenter'), invited_at (timestamptz)6// 4. comments: id (uuid PK), document_id (uuid FK to documents), user_id (uuid FK to auth.users), content (text), selection_json (jsonb), resolved (boolean default false), created_at (timestamptz)7// Add RLS policies: owner and collaborators can access their documents. Enable Realtime on documents table.Pro tip: Use V0's prompt queuing: queue the schema prompt first, then immediately queue the editor layout prompt. V0 processes them sequentially while you plan the next steps.
Expected result: Supabase is connected, all four tables are created with RLS policies, and Realtime replication is enabled on the documents table.
Build the document dashboard and navigation
Create the main dashboard where users see their recent documents, shared documents, and can create new ones. This is a Server Component that fetches documents from Supabase.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Button } from '@/components/ui/button'4import { Badge } from '@/components/ui/badge'5import Link from 'next/link'6import { createDocument } from './actions'78export default async function DashboardPage() {9 const supabase = await createClient()10 const { data: { user } } = await supabase.auth.getUser()1112 const { data: ownDocs } = await supabase13 .from('documents')14 .select('id, title, updated_at')15 .eq('owner_id', user?.id)16 .order('updated_at', { ascending: false })17 .limit(12)1819 const { data: sharedDocs } = await supabase20 .from('collaborators')21 .select('role, documents(id, title, updated_at)')22 .eq('user_id', user?.id)23 .limit(12)2425 return (26 <div className="container mx-auto py-8">27 <div className="flex justify-between items-center mb-6">28 <h1 className="text-3xl font-bold">My Documents</h1>29 <form action={createDocument}>30 <Button type="submit">New Document</Button>31 </form>32 </div>33 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">34 {ownDocs?.map((doc) => (35 <Link key={doc.id} href={`/documents/${doc.id}`}>36 <Card className="hover:shadow-md transition-shadow">37 <CardHeader>38 <CardTitle className="text-lg">{doc.title || 'Untitled'}</CardTitle>39 </CardHeader>40 <CardContent>41 <p className="text-sm text-muted-foreground">42 Updated {new Date(doc.updated_at).toLocaleDateString()}43 </p>44 </CardContent>45 </Card>46 </Link>47 ))}48 </div>49 {sharedDocs && sharedDocs.length > 0 && (50 <>51 <h2 className="text-2xl font-semibold mt-10 mb-4">Shared with me</h2>52 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">53 {sharedDocs.map((item) => (54 <Link key={item.documents.id} href={`/documents/${item.documents.id}`}>55 <Card>56 <CardHeader>57 <CardTitle className="flex items-center gap-2 text-lg">58 {item.documents.title || 'Untitled'}59 <Badge variant="outline">{item.role}</Badge>60 </CardTitle>61 </CardHeader>62 </Card>63 </Link>64 ))}65 </div>66 </>67 )}68 </div>69 )70}Expected result: The dashboard shows owned documents in a card grid and shared documents in a separate section, with a New Document button that creates a blank doc via Server Action.
Build the real-time editor with presence indicators
Create the main editor page as a 'use client' component that uses Supabase Realtime Broadcast for cursor positions and content synchronization. The editor shows active collaborators with colored cursors.
1'use client'23import { useEffect, useState, useCallback } from 'react'4import { createClient } from '@/lib/supabase/client'5import { Avatar, AvatarFallback } from '@/components/ui/avatar'6import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'7import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarTrigger } from '@/components/ui/menubar'89type Presence = { userId: string; name: string; color: string; cursor: number }1011export function CollaborativeEditor({ docId, initialContent, userId, userName }: {12 docId: string13 initialContent: string14 userId: string15 userName: string16}) {17 const [content, setContent] = useState(initialContent)18 const [collaborators, setCollaborators] = useState<Presence[]>([])19 const supabase = createClient()2021 useEffect(() => {22 const channel = supabase.channel(`doc:${docId}`)2324 channel25 .on('broadcast', { event: 'content' }, ({ payload }) => {26 if (payload.userId !== userId) {27 setContent(payload.content)28 }29 })30 .on('broadcast', { event: 'presence' }, ({ payload }) => {31 setCollaborators((prev) => {32 const filtered = prev.filter((p) => p.userId !== payload.userId)33 return [...filtered, payload as Presence]34 })35 })36 .subscribe()3738 return () => {39 supabase.removeChannel(channel)40 }41 }, [docId, userId, supabase])4243 const handleChange = useCallback(44 (newContent: string) => {45 setContent(newContent)46 const channel = supabase.channel(`doc:${docId}`)47 channel.send({48 type: 'broadcast',49 event: 'content',50 payload: { userId, content: newContent },51 })52 },53 [docId, userId, supabase]54 )5556 return (57 <div className="min-h-screen">58 <Menubar className="sticky top-0 z-10">59 <MenubarMenu>60 <MenubarTrigger>Format</MenubarTrigger>61 <MenubarContent>62 <MenubarItem>Bold</MenubarItem>63 <MenubarItem>Italic</MenubarItem>64 <MenubarItem>Heading</MenubarItem>65 </MenubarContent>66 </MenubarMenu>67 </Menubar>68 <div className="flex items-center gap-1 p-2 border-b">69 {collaborators.map((c) => (70 <Tooltip key={c.userId}>71 <TooltipTrigger>72 <Avatar className="h-7 w-7" style={{ borderColor: c.color }}>73 <AvatarFallback className="text-xs">{c.name[0]}</AvatarFallback>74 </Avatar>75 </TooltipTrigger>76 <TooltipContent>{c.name}</TooltipContent>77 </Tooltip>78 ))}79 </div>80 <textarea81 className="w-full min-h-[70vh] p-8 text-lg focus:outline-none resize-none"82 value={content}83 onChange={(e) => handleChange(e.target.value)}84 />85 </div>86 )87}Pro tip: Queue separate prompts for the editor, presence layer, and version history UI. V0 handles up to 10 queued prompts, so you can plan ahead while the first generates.
Expected result: Multiple users editing the same document see each other's changes in real-time. Presence indicators show who's currently active with colored avatars.
Add version history with snapshot restore
Build the version history page that shows a timeline of document snapshots. Users can compare versions and restore previous states. Versions are saved via a Server Action triggered manually or auto-saved periodically.
1// Paste this prompt into V0's AI chat:2// Build a version history page at app/documents/[id]/history/page.tsx.3// Requirements:4// - Server Component that fetches all document_versions for this document, ordered by version_number DESC5// - Display versions in a vertical timeline layout using Card components6// - Each version card shows: version_number as Badge, created_by user name, created_at formatted date, and a preview snippet of the content (first 100 chars)7// - Add a "Restore this version" Button on each card that calls a Server Action to copy that version's content back to the documents table and create a new version entry8// - Add a "Compare" Button that opens a Dialog showing the diff between the selected version and the current document content9// - Use ScrollArea for the timeline if there are many versions10// - Include a "Save Version" Button at the top that manually creates a snapshot of the current content11// - Add a "Back to editor" link using shadcn/ui Button variant="ghost"Expected result: The history page shows a timeline of all saved versions. Users can restore any previous version or compare it with the current document content.
Build collaborator management and sharing controls
Create the sharing interface where document owners invite collaborators with specific roles. Use a Dialog for the invite flow and a DropdownMenu for changing existing collaborator permissions.
1// Paste this prompt into V0's AI chat:2// Build a sharing panel for the document editor.3// Requirements:4// - A Sheet component (slides in from right) triggered by a "Share" Button in the editor toolbar5// - Show current collaborators in a list with Avatar, name, email, and role Badge (editor/viewer/commenter)6// - Each collaborator row has a DropdownMenu to change role or remove access7// - An invite section at the top with Input for email, Select for role, and Button to invite8// - The invite calls a Server Action that inserts into collaborators table and sends an email notification (placeholder for now)9// - Show the document owner at the top of the list with a "Owner" Badge that can't be changed10// - Add a Switch to toggle "Anyone with the link can view" for public sharing11// - Display a copyable share URL using Button with copy-to-clipboard functionality12// - Use Separator between the invite section and the collaborator listPro tip: Use Design Mode (Option+D) to adjust the Sheet width, collaborator list spacing, and role Badge colors without spending credits.
Expected result: Document owners can invite collaborators by email, assign roles, and manage permissions. A share link with public toggle allows broader access control.
Add inline comments anchored to text selections
Build the commenting system that lets collaborators select text and attach comments. Comments appear as Popover components positioned near the selected text.
1// Paste this prompt into V0's AI chat:2// Build an inline commenting system for the document editor.3// Requirements:4// - When a user selects text in the editor and clicks a "Comment" Button (or uses Cmd+Shift+C), open a Popover near the selection5// - The Popover contains a Textarea for the comment and a Button to submit6// - Store the comment with selection_json containing the start/end positions of the selected text7// - Show existing comments as highlighted text spans in the editor with colored underlines8// - Clicking a highlighted span opens its comment thread in a Popover showing:9// - Original commenter Avatar and name10// - Comment text and timestamp11// - Reply thread with additional comments12// - A "Resolve" Button that marks the comment as resolved and removes the highlight13// - Show a comments sidebar (Sheet from right) listing all unresolved comments with their text context14// - Use Badge to show comment count in the editor toolbar15// - Store comments via Server Action with Zod validation (content required, 1-500 chars)Expected result: Users can select text and add inline comments. Comments appear as highlighted spans, and a sidebar shows all unresolved comments for easy navigation.
Complete code
1import { createClient } from '@/lib/supabase/server'2import { NextRequest, NextResponse } from 'next/server'34export async function GET(5 req: NextRequest,6 { params }: { params: Promise<{ id: string }> }7) {8 const { id } = await params9 const supabase = await createClient()1011 const { data: doc, error } = await supabase12 .from('documents')13 .select(`14 *,15 collaborators(user_id, role),16 document_versions(id, version_number, created_at)17 `)18 .eq('id', id)19 .single()2021 if (error || !doc) {22 return NextResponse.json({ error: 'Document not found' }, { status: 404 })23 }2425 return NextResponse.json({ data: doc })26}2728export async function PATCH(29 req: NextRequest,30 { params }: { params: Promise<{ id: string }> }31) {32 const { id } = await params33 const supabase = await createClient()34 const body = await req.json()3536 const { data, error } = await supabase37 .from('documents')38 .update({39 title: body.title,40 content: body.content,41 updated_at: new Date().toISOString(),42 })43 .eq('id', id)44 .select()45 .single()4647 if (error) {48 return NextResponse.json({ error: error.message }, { status: 500 })49 }5051 return NextResponse.json({ data })52}5354export async function DELETE(55 req: NextRequest,56 { params }: { params: Promise<{ id: string }> }57) {58 const { id } = await params59 const supabase = await createClient()6061 const { error } = await supabase62 .from('documents')63 .delete()64 .eq('id', id)6566 if (error) {67 return NextResponse.json({ error: error.message }, { status: 500 })68 }6970 return NextResponse.json({ success: true })71}Customization ideas
Add rich text with Tiptap editor
Replace the textarea with Tiptap (ProseMirror-based) for full rich text editing with headings, lists, code blocks, and image embeds — all while keeping Supabase Realtime sync.
Add document templates
Create a template gallery (meeting notes, project brief, design doc) that pre-populates new documents with structured content and placeholder sections.
Add AI writing assistant
Integrate OpenAI via an API route to offer AI-powered writing suggestions, summaries, and content expansion directly within the editor toolbar.
Add export to PDF and Markdown
Build an API route that converts document content to PDF using @react-pdf/renderer or to Markdown for download and external sharing.
Add real-time notifications for mentions
Detect @username mentions in comments and document content, then send push notifications via Supabase Realtime to the mentioned user's active sessions.
Common pitfalls
Pitfall: Not unsubscribing from Supabase Realtime channels on component unmount
How to avoid: Always return a cleanup function from useEffect that calls supabase.removeChannel(channel) to properly unsubscribe when the component unmounts.
Pitfall: Storing document content as plain text instead of structured JSON
How to avoid: Use a jsonb column for content that stores structured data (e.g., Tiptap or ProseMirror JSON format), which preserves formatting and enables precise comment anchoring.
Pitfall: Broadcasting every keystroke over the Realtime channel
How to avoid: Debounce content broadcasts by 300-500ms so changes are batched. Send cursor position updates separately at a lower frequency.
Pitfall: Using NEXT_PUBLIC_ prefix for the Supabase service role key
How to avoid: Use NEXT_PUBLIC_SUPABASE_ANON_KEY for client-side Realtime subscriptions and SUPABASE_SERVICE_ROLE_KEY (no prefix) only in Server Actions and API routes.
Best practices
- Use Supabase Realtime Broadcast (not Postgres Changes) for collaboration — Broadcast is lower latency and designed for ephemeral events like cursor positions and typing indicators.
- Debounce content synchronization to avoid overwhelming the Realtime channel. Send batched updates every 300-500ms instead of on every keystroke.
- Save version snapshots periodically (every 5 minutes of active editing) and on manual save, not on every content change.
- Use Server Components for the dashboard and history pages, and 'use client' only for the interactive editor workspace.
- Leverage V0's prompt queuing to build the editor, sync layer, and history UI as three sequential prompts in one session.
- Store collaborator roles in the database and check them in RLS policies — never rely on client-side role checks for access control.
- Use Design Mode (Option+D) to fine-tune the editor toolbar layout and presence indicator styling without spending credits.
- Enable connection pooling via Supavisor for the API routes to prevent connection exhaustion in serverless environments.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a collaborative document editor with Next.js App Router and Supabase Realtime. I need to implement real-time cursor tracking for multiple users. Show me how to use Supabase Broadcast channels to send and receive cursor position updates, display colored cursors for each collaborator, and handle presence (join/leave) events. Include the useEffect setup with proper cleanup.
Build the real-time presence system for a collaborative editor. Create a 'use client' component that subscribes to a Supabase Broadcast channel named doc:{id}. Broadcast cursor positions on selection change. Display collaborator Avatar components with Tooltip showing their name. Use different colors per user. Include proper cleanup in useEffect. Show a typing indicator Badge when a collaborator is actively editing.
Frequently asked questions
Do I need a paid Supabase plan for real-time collaboration?
The free Supabase tier supports Realtime, but with limits on concurrent connections. For a small team (under 200 concurrent users), the free tier works. For production use with many collaborators, upgrade to the Pro plan ($25/month) for higher connection limits.
How do I handle conflicts when two users edit the same paragraph?
For a simple implementation, use last-write-wins with debounced updates. For production quality, implement operational transform (OT) or use a library like Yjs that handles conflict resolution automatically and integrates with Supabase Realtime as the transport layer.
What V0 plan do I need for this project?
The Premium plan ($20/month) is recommended since the editor has many interactive components that require multiple prompt iterations. The free tier works but may require more manual code editing.
Can I deploy this to a custom domain?
Yes. After publishing to Vercel via V0's Share menu, go to the Vercel Dashboard to add a custom domain. Update your DNS records and Vercel handles SSL automatically.
How do I add rich text formatting beyond plain text?
Integrate Tiptap (built on ProseMirror) as the editor engine. Prompt V0 to add Tiptap with extensions for bold, italic, headings, lists, and code blocks. Store the structured JSON output in the documents.content jsonb column.
Can RapidDev help build a custom document collaboration tool?
Yes. RapidDev has built 600+ apps including real-time collaboration platforms with advanced features like operational transform, granular permissions, and audit logging. Book a free consultation to discuss your specific requirements.
How do I prevent unauthorized access to documents?
Supabase RLS policies restrict document access to the owner and invited collaborators. Set policies that check auth.uid() against the documents.owner_id or collaborators.user_id columns. All API routes and Server Actions inherit these restrictions.
Can multiple people comment on the same text selection?
Yes. Comments are stored as separate rows in the comments table, each with their own selection_json. Multiple comments can reference overlapping text ranges, and the UI renders them as stacked Popover components.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation