Build a Real-Time Collaboration Editor with Supabase Realtime and Next.js
WA
Build a Real-Time Collaboration Editor with Supabase Realtime and Next.js
Building a real-time collaboration editor is one of the highest-leverage features you can add to a SaaS product. Users expect Google Docs-style simultaneous editing, live cursors, and instant synchronization — and if you're a solo founder, you need a backend that handles this complexity without requiring you to manage WebSocket servers or deploy complex infrastructure.
Supabase Realtime combined with Next.js makes this genuinely achievable in a weekend. This post walks you through building a production-ready document editor where multiple users can edit simultaneously, see live changes, and never lose data.
Why Real-Time Collaboration Matters for Your SaaS
Collaboration features are what separate good products from great ones. They increase stickiness, justify higher pricing tiers, and are the #1 reason teams switch away from single-user tools. The problem is that building real-time sync correctly is hard — you need to handle conflicts, race conditions, and network failures.
Supabase Realtime abstracts away the WebSocket layer and gives you PostgreSQL-backed synchronization out of the box. Combined with Next.js API routes and the Supabase client library, you can ship collaboration features without building a custom backend.
Architecture Overview
Here's the pattern we'll use:
- Supabase PostgreSQL stores documents and edit history
- Supabase Realtime broadcasts changes to all connected clients via WebSockets
- Operational Transforms (OT) resolve simultaneous edits
- Next.js serves the frontend and handles API routes for batch operations
- Client-side React manages local state, optimistic updates, and conflict resolution
The key insight: instead of sending raw text diffs, we'll store edit operations in a table. Supabase Realtime watches that table and broadcasts inserts to all subscribers. The client applies those operations in order, transforming them against local changes to avoid conflicts.
Setting Up the Database Schema
First, create your tables in Supabase:
-- Documents table
create table documents (
id uuid primary key default gen_random_uuid(),
title text not null,
content text not null,
owner_id uuid not null references auth.users(id),
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now()
);
-- Collaborative editing operations
create table document_operations (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references documents(id) on delete cascade,
user_id uuid not null references auth.users(id),
operation jsonb not null, -- {type: 'insert'|'delete', position: number, content?: string}
version integer not null, -- Operation sequence number
created_at timestamp with time zone default now()
);
-- Track user presence (cursors)
create table document_presence (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references documents(id) on delete cascade,
user_id uuid not null references auth.users(id),
cursor_position integer,
selection_start integer,
selection_end integer,
color text,
updated_at timestamp with time zone default now()
);
-- Enable realtime for operations table
alter publication supabase_realtime add table document_operations;
alter publication supabase_realtime add table document_presence;
-- Create indexes for performance
create index idx_operations_document on document_operations(document_id, version);
create index idx_presence_document on document_presence(document_id);
Implementing the Client-Side Editor
Create a React component that subscribes to Realtime changes:
// components/CollaborativeEditor.tsx
import { useState, useEffect, useRef } from 'react';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { supabase } from '@/lib/supabase';
export function CollaborativeEditor({ documentId }: { documentId: string }) {
const [content, setContent] = useState('');
const [operations, setOperations] = useState<any[]>([]);
const [version, setVersion] = useState(0);
const localChanges = useRef<any[]>([]);
// Subscribe to real-time operation broadcasts
useEffect(() => {
const subscription = supabase
.channel(`doc:${documentId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'document_operations',
filter: `document_id=eq.${documentId}`,
},
(payload) => {
const incomingOp = payload.new;
// Transform local changes against incoming operation
const transformed = transformOperations(
localChanges.current,
incomingOp
);
localChanges.current = transformed;
// Apply operation to content
const newContent = applyOperation(content, incomingOp);
setContent(newContent);
setVersion(incomingOp.version);
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [documentId, content]);
const handleChange = async (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
const operation = {
type: newText.length > content.length ? 'insert' : 'delete',
position: e.target.selectionStart,
content: newText.length > content.length
? newText.slice(content.length)
: '',
};
// Optimistically update local content
setContent(newText);
// Queue operation for server
localChanges.current.push(operation);
// Send to server
await supabase.from('document_operations').insert({
document_id: documentId,
user_id: (await supabase.auth.getUser()).data.user?.id,
operation,
version: version + 1,
});
};
return (
<textarea
value={content}
onChange={handleChange}
className="w-full h-96 p-4 border rounded font-mono"
spellCheck="false"
/>
);
}
Operational Transform Logic
The real magic happens in the transform functions. These resolve conflicts between simultaneous edits:
// lib/ot.ts
interface Operation {
type: 'insert' | 'delete';
position: number;
content?: string;
}
export function applyOperation(content: string, op: Operation): string {
if (op.type === 'insert') {
return (
content.slice(0, op.position) +
(op.content || '') +
content.slice(op.position)
);
} else {
const deleteLength = op.content?.length || 1;
return (
content.slice(0, op.position) +
content.slice(op.position + deleteLength)
);
}
}
export function transformOperations(
local: Operation[],
incoming: Operation
): Operation[] {
return local.map((localOp) => {
// If operations don't overlap, no transformation needed
if (
localOp.position >= incoming.position + (incoming.content?.length || 1) ||
incoming.position >= localOp.position + (localOp.content?.length || 1)
) {
return localOp;
}
// Adjust positions based on operation type
if (incoming.type === 'insert') {
if (localOp.position >= incoming.position) {
return {
...localOp,
position: localOp.position + (incoming.content?.length || 0),
};
}
} else {
const deleteLength = incoming.content?.length || 1;
if (localOp.position > incoming.position) {
return {
...localOp,
position: Math.max(
incoming.position,
localOp.position - deleteLength
),
};
}
}
return localOp;
});
}
Handling Presence (Live Cursors)
Add cursor tracking for a polished UX:
// Update cursor position on selection change
const handleSelectionChange = async (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
const user = (await supabase.auth.getUser()).data.user;
if (!user) return;
await supabase.from('document_presence').upsert({
document_id: documentId,
user_id: user.id,
cursor_position: target.selectionStart,
selection_start: target.selectionStart,
selection_end: target.selectionEnd,
color: generateUserColor(user.id),
updated_at: new Date().toISOString(),
});
};
Deployment Considerations
Since everything is Supabase and Next.js, deployment is straightforward:
- Deploy Next.js to Vercel (free tier works fine for most products)
- Supabase Realtime works automatically with your PostgreSQL database
- Enable Row Level Security (RLS) to ensure users only see documents they have access to
- Add a
user_document_accesstable if you need fine-grained permissions
Wrapping Up
What took companies like Google months to build, you can now ship in a day using Supabase Realtime. The key is leaning on PostgreSQL for durability and Realtime for the synchronization layer.
This pattern scales from 2 simultaneous editors to hundreds because Supabase handles the broadcast infrastructure. The only optimization you might need is debouncing operations on the client side if you're building a real-time code editor with very frequent changes.
Start with this foundation, add presence indicators, improve the OT logic for your specific use case, and you have a genuine competitive feature that investors and customers will notice.