A calm, collaborative document editor: live co-editing with multiplayer cursors, in-doc chat,
version history, and sharing - all synced to the cloud, free to run. (Working name, rename freely -
it's just the metadata.title in app/layout.tsx and the brand mark in the top bars.)
- Next.js (App Router) - hosted on Vercel's free (Hobby) tier
- Supabase free tier - Postgres, Auth, Storage, and Realtime
- Yjs (CRDT) + Tiptap for the editor, synced over a custom provider built on Supabase
Realtime Broadcast (see
lib/yjs/supabase-provider.tsfor why: Vercel functions can't hold persistent WebSocket connections, so the realtime transport has to live on Supabase's side)
No billing/pricing logic anywhere - every feature described below is unconditionally available.
- Email/password auth, folders + documents dashboard
- Real-time collaborative editing with multiplayer cursors and presence avatars
- Comments anchored to text via Yjs relative positions (survive concurrent edits elsewhere in the doc)
- In-document chat
- Version history: manual "Save version" snapshots, restore, pruned to the last 50 per document
- Sharing: invite collaborators by email with a role (viewer/commenter/editor), or generate a role-scoped link that anyone signed in can use to join
- Focus mode, dark/light mode, a handful of accent themes
- Live word count, Markdown export
- Works offline-ish: edits persist to IndexedDB locally and sync once reconnected
- Create a Supabase project at supabase.com (free tier).
- Run the migrations in
supabase/migrations/against your project, in order - either via the Supabase CLI (supabase link, thensupabase db push) or by pasting each file's contents into the SQL Editor in the Supabase dashboard, in filename order (0001, 0002, ...). - Enable private Realtime channels: Project Settings > Realtime > turn off "Allow public
access to all channels" (the migrations lock channel access down to collaborators via RLS on
realtime.messages, but that only takes effect once public access is off). - Copy env vars:
cp .env.local.example .env.local, then fill in:NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEY- Project Settings > APISUPABASE_SERVICE_ROLE_KEY- same page (server-only, used by the maintenance cron)CRON_SECRET- any random string
- Install deps and run:
Open http://localhost:3000.
npm install npm run dev
- Push this repo to GitHub, then import it in Vercel.
- Add the same env vars from
.env.localto the Vercel project's Environment Variables. - Deploy.
vercel.jsonalready declares the daily maintenance cron (/api/cron/maintenance) - Vercel's Hobby plan runs it once a day and automatically sendsAuthorization: Bearer $CRON_SECRET, which the route checks. That job does two things: prunes version history past 50 snapshots per document, and touches the database so Supabase's free project doesn't auto-pause after 7 days of inactivity.
- Document sync isn't "read-modify-write a snapshot column." Every edit appends to an
append-only
document_updateslog (concurrent inserts never conflict);documents.content_snapshotis just a periodic compaction of that log, written under an optimistic-concurrency check (snapshot_version). Seelib/yjs/compact.tsfor why a naive locked read-modify-write doesn't work well against a serverless/pooled Postgres connection. - Realtime Broadcast channels are private and RLS-gated (
supabase/migrations/0003_rls_realtime.sql) - without this, anyone who learns a document's UUID could join its live channel regardless of the collaborators table. profiles.emailis locked down at the column-privilege level, not just row-level - the only reader is thefind_user_by_emailRPC used for invites. The client always gets "my own email" from the auth session, never from this table.- Types aren't generated from the live schema yet (
supabase gen types typescriptneeds a linked project) -lib/types.tsis hand-written and the Supabase client isn't generic-typed. Worth doing once the project is linked; low urgency since RLS is the actual authorization boundary, not the TS types.