Real-Time Collaborative Stack: Supabase + Yjs + Tauri 2 + SvelteKit
Build production-ready real-time collaborative applications with Supabase, Yjs CRDTs, Tauri 2 desktop framework, and SvelteKit. Includes real-time sync, offline-first strategies, and multi-platform packaging.
- Step 1
Project Initialization
Set up a monorepo structure with separate packages for the web app, desktop app, and shared types. Use pnpm workspaces for efficient dependency management.
mkdir collaborative-app && cd collaborative-app pnpm init pnpm add -wD pnpm-workspace echo 'packages:' > pnpm-workspace.yaml echo ' - packages/*' >> pnpm-workspace.yaml mkdir -p packages/{web,desktop,shared} - Step 2
Initialize SvelteKit Web App
Create a SvelteKit 2 project with TypeScript and Tailwind CSS. This will serve as the foundation for the web application.
cd packages/web pnpm create svelte@latest . --template skeleton --typescript --prettier --eslint --playwright --vite cd .. pnpm install sveltekit-superforms @hookform/svelte pnpm install zod @tanstack/svelte-query pnpm install -D prettier prettier-plugin-svelte svelte-check - Step 3
Install Yjs and Supabase Provider
Add Yjs CRDT library and Supabase for real-time synchronization. Yjs provides conflict-free replicated data types essential for collaborative editing.
cd packages/web pnpm install yjs lib0 pnpm install @supabase/supabase-js pnpm install y-indexeddb⚠ Heads up: Yjs has multiple packages. Install 'yjs' for the core CRDT library and 'y-indexeddb' for offline persistence. - Step 4
Configure Supabase Real-Time Database
Set up Supabase with real-time subscriptions and database tables for Yjs document storage and awareness.
-- Enable necessary extensions create extension if not exists "uuid-ossp"; -- Storage for Yjs documents create table y_docs ( id uuid primary key default uuid_generate_v4(), doc_name text unique not null, content text, updated_at timestamptz default now(), created_at timestamptz default now() ); -- Awareness storage for user presence create table y_awareness ( id uuid primary key default uuid_generate_v4(), doc_name text not null references y_docs(doc_name), user_id uuid not null, awareness text, updated_at timestamptz default now(), expires_at timestamptz not null ); -- Index for expired awareness cleanup create index y_awareness_expires_idx on y_awareness(expires_at); -- Enable real-time subscriptions alter publication supabase_realtime add table y_docs; alter publication supabase_realtime add table y_awareness; -- Enable Row Level Security alter table y_docs enable row level security; alter table y_awareness enable row level security; -- RLS Policies create policy "Public read access to documents" on y_docs for select using (true); create policy "Authenticated users can modify documents" on y_docs for all using (auth.role() = 'authenticated'); - Step 5
Create Yjs Supabase Provider
Implement Yjs document synchronization with Supabase for real-time collaboration.
// packages/shared/supabase-provider.ts import * as Y from 'yjs' import * as syncProtocol from 'y-protocols/sync' import * as awarenessProtocol from 'y-protocols/awareness' import type { SupabaseClient } from '@supabase/supabase-js' export class SupabaseYjsProvider { public ydoc: Y.Doc public awareness: awarenessProtocol.Awareness private _supabase: SupabaseClient private _docName: string constructor(options: { supabase: SupabaseClient docName: string userId: string }) { this._supabase = options.supabase this._docName = options.docName this.ydoc = new Y.Doc() this.ydoc.on('update', (update) => this._saveUpdate(update)) this.awareness = new awarenessProtocol.Awareness(this.ydoc, { connectionId: options.userId, localState: {} }, null) this.awareness.setLocalStateField('user', { name: options.userId, color: '#' + Math.floor(Math.random()*16777215).toString(16) }) this._loadDoc() this._subscribeToUpdates() } private async _loadDoc() { const { data, error } = await this._supabase .from('y_docs') .select('content') .eq('doc_name', this._docName) .maybeSingle() if (error || !data?.content) return const update = this._hexToUint8(data.content) syncProtocol.applyUpdate(this.ydoc, update) } private async _saveUpdate(update: Uint8Array) { const hex = this._uint8ToHex(update) await this._supabase.from('y_docs').upsert({ doc_name: this._docName, content: hex, updated_at: new Date().toISOString() }) } private _subscribeToUpdates() { this._supabase .channel(`doc:${this._docName}`) .on('postgres_changes', { event: '*', schema: 'public', table: 'y_docs', filter: { doc_name: this._docName } }, async (payload) => { if (['INSERT', 'UPDATE'].includes(payload.eventType)) { const update = this._hexToUint8(payload.new.content) syncProtocol.applyUpdate(this.ydoc, update) } }) .subscribe() } private _hexToUint8(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < hex.length / 2; i++) { bytes[i] = parseInt(hex.substr(i * 2, 2), 16) } return bytes } private _uint8ToHex(arr: Uint8Array): string { return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('') } destroy() { this._supabase.removeChannel(`doc:${this._docName}`) this.ydoc.destroy() } } - Step 6
Build Collaborative Editor in SvelteKit
Create a real-time collaborative text editor component using SvelteKit and Yjs.
<script lang="ts"> import { onMount } from 'svelte' import * as Y from 'yjs' import { createBrowserClient } from '@supabase/ssr' import { SupabaseYjsProvider } from '@shared/supabase-provider' export let docName = 'shared-doc' export let userId = 'user-1' let provider: SupabaseYjsProvider let yText: Y.Text let content = $state('') let collaborators = $state<Map<string, any>>(new Map()) $effect(() => { if (yText) content = yText.toString() }) onMount(() => { const supabase = createBrowserClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY ) provider = new SupabaseYjsProvider({ supabase, docName, userId }) yText = provider.ydoc.getText('content') provider.awareness.on('update', () => { const states = provider.awareness.getStates() collaborators = new Map(Object.entries(states)) }) return () => provider.destroy() }) </script> <div class="collaborative-editor"> <div class="presence-bar"> <span>Present:</span> {#each collaborators as [id, state]} {#if state.user} <span class="collaborator" style="background: {state.user.color}"> {state.user.name} </span> {/if} {/each} </div> <textarea bind:value={content} placeholder="Start collaborating..." /> </div> <style> .collaborative-editor { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; } .presence-bar { background: #f9fafb; padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; } .collaborator { padding: 2px 8px; border-radius: 10px; color: white; font-size: 0.75rem; } textarea { width: 100%; min-height: 300px; padding: 1rem; border: none; font-family: inherit; resize: vertical; } </style> - Step 7
Initialize Tauri 2 Desktop Application
Set up Tauri 2 for desktop applications with native integrations.
cd packages/desktop pnpm create tauri-app@latest . --template svelte-kit --manager pnpm rustc --version cargo --version tauri info⚠ Heads up: Tauri requires Rust. Install via rustup.rs and ensure proper toolchain is set up. - Step 8
Configure Tauri Multi-Platform Build
Set up build configurations for Windows, macOS, and Linux distributions.
{ "$schema": "https://schema.tauri.app/config/2", "productName": "Collaborative Editor", "version": "0.1.0", "identifier": "com.collaborative.app", "build": { "frontendDist": "../web/build", "beforeDevCommand": "pnpm dev", "beforeBuildCommand": "pnpm build" }, "tauri": { "windows": [{ "title": "Collaborative Editor", "width": 1200, "height": 800, "resizable": true }], "bundle": { "active": true, "targets": ["app", "dmg", "msi", "deb"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"] } } } - Step 9
Add WebRTC for P2P Communication
Implement WebRTC for peer-to-peer real-time communication alongside the central sync.
// packages/shared/webrtc.ts export class WebRTCPeer { private pc: RTCPeerConnection constructor(config: RTCConfiguration = {}) { this.pc = new RTCPeerConnection({ iceServers: [ { urls: ['stun:stun.l.google.com:19302'] }, ...(config.iceServers || []) ] }) this.pc.onconnectionstatechange = () => { console.log('Connection state:', this.pc.connectionState) } } createDataChannel(label: string) { return this.pc.createDataChannel(label, { ordered: false }) } async createOffer(): Promise<RTCSessionDescriptionInit> { const offer = await this.pc.createOffer() await this.pc.setLocalDescription(offer) return offer.toJSON() } async createAnswer(offer: RTCSessionDescriptionInit) { await this.pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await this.pc.createAnswer() await this.pc.setLocalDescription(answer) return answer.toJSON() } addIceCandidate(candidate: RTCIceCandidateInit) { this.pc.addIceCandidate(new RTCIceCandidate(candidate)) } close() { this.pc.close() } }⚠ Heads up: For production, add TURN servers for NAT traversal when STUN fails. - Step 10
Implement Offline-First Sync
Build offline-first capabilities with IndexedDB persistence and queue-based sync.
// packages/shared/offline-provider.ts import * as Y from 'yjs' import { IndexeddbPersistence } from 'y-indexeddb' export class OfflineYjsProvider { private ydoc: Y.Doc private storage: IndexeddbPersistence private syncQueue: Uint8Array[] = [] constructor(docName: string) { this.ydoc = new Y.Doc() this.storage = new IndexeddbPersistence(docName, this.ydoc) window.addEventListener('online', () => this.syncQueuedUpdates()) } get yText(): Y.Text { return this.ydoc.getText('content') } private async syncQueuedUpdates() { if (navigator.onLine && this.syncQueue.length > 0) { const merged = Y.mergeUpdates(this.syncQueue) this.syncQueue = [] // Send to server... } } queueUpdate(update: Uint8Array) { if (!navigator.onLine) { this.syncQueue.push(update) } } destroy() { this.storage.close() this.ydoc.destroy() } } - Step 11
Scalability Optimizations
Implement batching, compression, and connection pooling for large teams.
// packages/shared/scalability.ts import * as Y from 'yjs' export class UpdateBatcher { private updates: Uint8Array[] = [] private timeout: ReturnType<typeof setTimeout> private batchSize: number private flushFn: (update: Uint8Array) => void constructor(batchSize: number, flushFn: (update: Uint8Array) => void) { this.batchSize = batchSize this.flushFn = flushFn this.scheduleFlush() } addUpdate(update: Uint8Array) { this.updates.push(update) if (this.updates.length >= this.batchSize) { this.flush() } else if (!this.timeout) { this.scheduleFlush() } } private scheduleFlush() { this.timeout = setTimeout(() => this.flush(), 1000) } private flush() { if (this.timeout) { clearTimeout(this.timeout); this.timeout = undefined } if (this.updates.length > 0) { const merged = Y.mergeUpdates(this.updates) this.updates = [] this.flushFn(merged) } } destroy() { if (this.timeout) { clearTimeout(this.timeout) } this.flush() } }⚠ Heads up: Large documents (>10MB) may cause browser memory issues. Split documents into blocks or use virtual scrolling.
Feature requests
Sign in to suggest features or vote on existing ones.
No feature requests yet.
Discussion
Sign in to join the discussion.
No comments yet.