A realtime collaborative document editor where teams capture not just what they wrote — but how and why it evolved.
Built with Next.js 16, BlockNote, Liveblocks (Yjs), Firebase/Firestore, and Clerk.
| Pillar | Description |
|---|---|
| Live Collaborative Editor | Multiple users edit simultaneously with live cursors, presence indicators, and conflict-free Yjs CRDT merging |
| Automatic Replay Timeline | Every 30 seconds of editing snapshots the document state, computes a structural diff, and stores it as a versioned checkpoint — automatically |
| Public Replay Sharing | Any owner can generate a /replay/[shareId] link — a read-only, unauthenticated viewer showing the full editing story chapter by chapter |
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Editor | BlockNote + @liveblocks/react-blocknote |
| Realtime | Liveblocks (Yjs CRDT, presence, threads) |
| Database | Firebase Firestore (Admin SDK server-side only) |
| Auth | Clerk |
| Styling | Tailwind CSS v4 |
| Data fetching | TanStack Query v5 |
| Validation | Zod |
| Error monitoring | Sentry |
| Package manager | Bun |
tandaan-app/
├── app/
│ ├── api/
│ │ ├── auth-endpoint/ # Liveblocks room auth (POST)
│ │ ├── clerk-webhook/ # Clerk user.created webhook (POST)
│ │ ├── documents/ # Document CRUD, versions, replay-share
│ │ ├── rooms/ # Room list + collaborators
│ │ └── users/ # Clerk user resolution by ID or email
│ ├── (auth)/ # Sign-in / sign-up pages
│ ├── documents/[id]/ # Live editor page
│ └── replay/[shareId]/ # Public replay viewer (no auth required)
├── components/
│ ├── documents/ # Editor, version timeline, toolbar
│ └── user/ # InviteUser, ManageUsers dialogs
├── hooks/
│ ├── useDocument.ts # Fetch + optimistic-update a document
│ ├── useRooms.ts # Fetch the current user's room list
│ └── useRoomUsers.ts # Fetch collaborators in a room
├── lib/
│ ├── api-utils.ts # requireAuth, apiErrorResponse, apiSuccessResponse
│ ├── liveblocks.ts # Liveblocks server client + resolveUsers
│ ├── schemas.ts # Zod schemas + parseBody helper
│ ├── stringToColor.ts # Deterministic color from string
│ ├── timestamp-utils.ts # Firestore Timestamp → ISO string
│ └── version-utils.ts # Block diff, preview text, replay index
├── services/
│ ├── actions.ts # createNewDocument, deleteDocument, restoreDocument
│ ├── replay.ts # Timeline fetch, share token generation
│ └── users.ts # searchUsers, inviteUser, removeUser, getRoomUsers
├── types/ # Shared TypeScript interfaces
├── firestore.rules # Firestore security rules (deploy with Firebase CLI)
├── firestore.indexes.json # Composite index definitions
├── firebase.ts # Client SDK (read-only)
├── firebase-admin.ts # Admin SDK (all server writes)
├── liveblocks.config.ts # Liveblocks global type declarations
└── middleware.ts # Clerk route protection
- Bun
>= 1.3.6 - Node.js
>= 20.9 - A Firebase project with Firestore enabled
- A Clerk application
- A Liveblocks account
git clone https://github.com/your-username/tandaan-app.git
cd tandaan-app
bun installCreate .env.local at the project root:
# ── Clerk ────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_... # Clerk dashboard → Webhooks → signing secret
# ── Firebase client SDK (safe to expose publicly) ────────────────────────────
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
# ── Firebase Admin (server-side only — never expose) ─────────────────────────
# Recommended: single JSON blob
FIREBASE_ADMIN_SERVICE_KEY={"type":"service_account","project_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\n..."}
# Alternative: split vars (useful when the platform doesn't support multi-line JSON)
# FIREBASE_ADMIN_PROJECT_ID=
# FIREBASE_ADMIN_CLIENT_EMAIL=
# FIREBASE_ADMIN_PRIVATE_KEY= # paste the key with literal \n for newlines
# ── Liveblocks ────────────────────────────────────────────────────────────────
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_...
LIVEBLOCKS_PRIVATE_KEY=sk_...
# ── Upstash Redis (rate limiting) ─────────────────────────────────────────────
UPSTASH_REDIS_REST_URL=https://your-upstash-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=
QSTASH_TOKEN=
SNAPSHOT_TASK_SECRET=
# ── PostHog (analytics + feature flags) ──────────────────────────────────────
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# ── Logging ───────────────────────────────────────────────────────────────────
LOG_LEVEL=info
# ── E2E smoke auth (optional) ─────────────────────────────────────────────────
E2E_CLERK_EMAIL=
E2E_CLERK_PASSWORD=
# ── Sentry (optional but recommended) ───────────────────────────────────────
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_AUTH_TOKEN=Never commit
.env.localorservice_key.json. Both are already in.gitignore.
npm install -g firebase-tools
firebase login
firebase use your-project-id
firebase deploy --only firestoreIn the Clerk dashboard → Webhooks, create an endpoint:
https://your-domain.com/api/clerk-webhook
Subscribe to the user.created event and paste the signing secret into CLERK_WEBHOOK_SECRET.
bun devOpen http://localhost:3000.
documents/{roomId}
title: string
content: string
createdAt: Timestamp
updatedAt: Timestamp
replayShareId?: string # set when owner generates a public share
replaySharedAt?: Timestamp
replaySharedBy?: string
versions/{versionId} # auto-created every 30s of editing
content: string
timeStamp: Timestamp
userId: string
summary: {
addedBlocks: number
updatedBlocks: number
removedBlocks: number
}
users/{userId}
email: string
firstName: string
lastName: string
createdAt: Timestamp
rooms/{roomId} # one entry per document the user can access
userId: string
role: "owner" | "editor"
roomId: string
createdAt: Timestamp
trash/{roomId} # soft-delete; expires after 30 days
...document fields
deleteAt: Timestamp
expiresAt: Timestamp
userId: string
roomId: string
| Method | Route | Auth | Description |
|---|---|---|---|
POST |
/api/auth-endpoint |
Required | Liveblocks room authentication |
POST |
/api/clerk-webhook |
Svix signature | Sync new Clerk user to Firestore |
GET |
/api/documents |
Required | List current user's documents |
GET |
/api/documents/[id] |
Required | Fetch document with role |
PATCH |
/api/documents/[id] |
Required | Update title |
GET |
/api/documents/[id]/versions |
Required | Fetch full replay timeline |
POST |
/api/documents/[id]/versions |
Required | Create a version snapshot |
POST |
/api/documents/[id]/replay-share |
Required | Generate public share token |
GET |
/api/rooms |
Required | List rooms with document metadata |
GET |
/api/rooms/[roomId]/users |
Required | List collaborators |
GET |
/api/users?userIds=... |
None | Resolve Clerk IDs/emails to profiles |
npm install -g vercel
vercel --prodAdd all environment variables under Vercel → Project → Settings → Environment Variables.
Tip for FIREBASE_ADMIN_SERVICE_KEY on Vercel: paste the entire JSON as a single line. Vercel stores it verbatim — the app will parse it with JSON.parse.
-
firebase deploy --only firestore(rules + indexes) - Clerk webhook URL updated to production domain
- All env vars set in production environment
- Sentry DSN configured
- Create a test document end-to-end, verify replay share works
bun dev # Start dev server (http://localhost:3000)
bun build # Production build
bun start # Start production server
bun typecheck # TypeScript check with no emit
bun lint # Biome lint
bun format # Biome format (writes files)- All Firestore writes go through the Admin SDK — the client SDK is initialized read-only and Firestore rules block all direct client writes.
- Webhook verification — the Clerk webhook at
/api/clerk-webhookverifies thesvix-signatureheader before touching Firestore. - Input validation — every API route that accepts a body uses Zod schemas (
lib/schemas.ts). - Ownership checks — delete, restore, and invite operations verify the requester's role in Firestore before executing.
- Structured logs — API routes emit JSON logs via
pinoand includerequestId. - Request correlation — middleware injects
x-request-id, and API responses mirror it. - Web vitals + feature flags — client metrics are captured with PostHog and mirrored to
/api/web-vitals. - Idempotency replay — document save/version APIs store response payloads for duplicate-key replays.
- Set
QSTASH_TOKEN,SNAPSHOT_TASK_SECRET, andNEXT_PUBLIC_APP_URLin staging env. - Trigger a version write via
POST /api/documents/:id/versionswith anx-idempotency-key. - Confirm API response includes
{ queued: true, success: true }. - Confirm
/api/documents/:id/snapshot-taskruns and a new Firestore version document is written.
- Fork and branch off
main - Run
bun typecheck && bun lintbefore opening a PR - One feature or fix per PR — keep diffs reviewable