Tandaan is a collaborative document workspace for work where the process matters, not just the final page.
The core product idea: documents with memory. Tandaan captures drafts, checkpoints, contributors, and replay timelines so users can review how a document evolved.
| Area | Technology |
|---|---|
| Framework | Next.js 16 App Router |
| Auth | Clerk |
| Realtime | Liveblocks + Yjs |
| Editor | BlockNote |
| Database | Firebase Firestore |
| Server writes | Firebase Admin SDK |
| Styling | Tailwind CSS v4 |
| Data fetching | TanStack Query |
| Tests | Playwright |
| Package manager | Bun |
src/
app/
(public)/ Landing page now; future blog/docs stay public.
(authentication)/ Clerk sign-in surfaces.
(protected)/ Documents and protected replay routes.
api/ Authenticated app APIs and webhooks.
layout.tsx Root layout with ClerkProvider.
globals.css
components/
layouts/ Header, sidebar, app shell.
shared/ Shared app components and wrappers.
ui/ Shadcn-generated components only.
features/
collaboration/ Liveblocks providers, cursors, room helpers.
documents/ Editor, document actions, replay, document hooks.
users/ Invite/manage users and user actions.
lib/
clerk/ Clerk helper functions.
firebase/ Firebase client/admin init and collection helpers.
liveblocks.ts
schemas.ts
utils.ts
types/| Route | Access | Purpose |
|---|---|---|
/ |
Public | Landing page |
/sign-in |
Public | Clerk sign in |
/documents |
Protected | Workspace document list |
/documents/[id] |
Protected | Collaborative editor |
/replay/[shareId] |
Protected | Replay timeline view |
| Method | Route | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth-endpoint |
Required | Liveblocks room auth |
POST |
/api/ai/batch-translate |
Required | Proxy to Cloudflare batch translation |
POST |
/api/ai/concept-deep-dive |
Required | Builds a project-history mastery report for one concept |
POST |
/api/ai/detect-language |
Required | Proxy to Cloudflare language detection |
POST |
/api/ai/suggest |
Required | Proxy to Cloudflare writing suggestion |
POST |
/api/ai/translate |
Required | Proxy to Cloudflare translation |
POST |
/api/clerk-webhook |
Svix signature | Sync Clerk users to Firestore |
GET |
/api/documents |
Required | List documents |
GET |
/api/documents/[id] |
Required | Fetch one document |
PATCH |
/api/documents/[id] |
Required | Update document title/content |
GET |
/api/documents/[id]/versions |
Required | Fetch replay timeline |
POST |
/api/documents/[id]/versions |
Required | Save replay checkpoint |
POST |
/api/documents/[id]/replay-share |
Required | Create replay share ID |
POST |
/api/liveblocks-webhook |
Liveblocks signature | Track Yjs updates for server replay snapshots |
POST |
/api/replay/snapshot-due |
CRON_SECRET |
Process rooms waiting for server replay snapshots |
GET |
/api/rooms |
Required | List rooms with document metadata |
GET |
/api/rooms/[roomId]/users |
Required | List collaborators |
GET |
/api/users |
Required | Resolve users |
GET |
/api/users/search |
Required | Search users for invites |
Firebase initialization lives only in:
src/lib/firebase/client.tssrc/lib/firebase/admin.ts
Collection helpers live in:
src/lib/firebase/documents.tssrc/lib/firebase/rooms.tssrc/lib/firebase/users.ts
Components and page files should not call Firestore directly.
Create .env.local:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
CLERK_WEBHOOK_SECRET=
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_SERVICE_KEY=
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
LIVEBLOCKS_PRIVATE_KEY=
LIVEBLOCKS_WEBHOOK_SECRET=
CLOUDFLARE_AI_WORKER_URL=
CLOUDFLARE_AI_WORKER_API_KEY=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_AUTH_TOKEN=
CRON_SECRET=Set CLOUDFLARE_AI_WORKER_URL to the deployed tandaan-cloudflare Worker base
URL, for example:
CLOUDFLARE_AI_WORKER_URL=https://tandaan-cloudflare.your-subdomain.workers.devIf the Worker requires a shared secret, set:
CLOUDFLARE_AI_WORKER_API_KEY=CLOUDFLARE_AI_WORKER_SECRET, TANDAAN_CLOUDFLARE_URL, and
TANDAAN_CLOUDFLARE_SECRET are also supported as aliases, but
CLOUDFLARE_AI_WORKER_URL and CLOUDFLARE_AI_WORKER_API_KEY are preferred in
this project.
The Next.js app uses the Worker server-side for:
- replay version narratives via
POST /generate-summary - protected translation proxy via
POST /api/ai/translate - protected batch translation proxy via
POST /api/ai/batch-translate - protected language detection proxy via
POST /api/ai/detect-language - protected writing suggestion proxy via
POST /api/ai/suggest - protected Concept Deep Dive proxy via
POST /api/ai/concept-deep-dive
Concept Deep Dive is intentionally user-triggered. The browser calls Tandaan's
backend only; the backend validates document access, loads document/replay
context, pulls Liveblocks comment threads, derives conservative struggle
signals, and then calls the Worker with x-api-key.
Validate the configured Worker environment without making an AI request:
bun run validate:cloudflare-aiValidate the deployed /concept-deep-dive response contract with a live
authenticated request:
bun run validate:cloudflare-ai -- --liveFIREBASE_ADMIN_SERVICE_KEY must be the full Firebase service account JSON as
one environment variable. Before deploying, validate it:
bun run validate:firebase-adminThe JSON must include:
{
"project_id": "your-firebase-project",
"client_email": "firebase-adminsdk-...",
"private_key": "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"
}For platforms like Vercel, paste the compact JSON into the environment variable
value and keep newline characters escaped as \n inside private_key. Do not
commit service_key.json; it is local-development fallback only.
Deploy Firestore indexes before testing collaboration and invite flows against a real Firebase project:
bun run deploy:firestore:indexesThe collaborator/member queries require a collection-group single-field index on
rooms.roomId. If it is missing, Firestore will throw FAILED_PRECONDITION
when opening the invite/collaborator UI.
Rate limits use Upstash Redis REST when these server-only variables are set:
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=Without them, Tandaan falls back to an in-memory limiter for local development. Do not rely on the fallback for production because serverless instances do not share memory.
bun install
bun run devIf Next dev gets stuck after route moves or proxy changes:
# stop bun run dev first
rm -rf .next
bun run devThe Clerk proxy is src/proxy.ts. Do not add a root proxy.ts.
bun x biome check --write src e2e docs package.json
bun x tsc --noEmit
bun run build
bun x playwright testCreate the reusable Clerk auth state once:
CLERK_E2E_EMAIL="test@example.com" \
CLERK_E2E_PASSWORD="password" \
bun x playwright test e2e/auth.setup.tsThis saves:
playwright/.auth/user.jsonThen run authenticated tests:
bun x playwright test e2e/authenticated.spec.tsOptional invite test:
PLAYWRIGHT_INVITE_EMAIL="teammate@example.com" \
bun x playwright test e2e/authenticated.spec.tsTandaan should not compete as a generic notes app. It should compete as a document memory and replay layer.
Use Tandaan when you need to know:
- how the draft changed
- who contributed
- what decisions shaped the final version
- what the work looked like before review
- how to present proof of process
Supporting docs:
docs/product-direction.mddocs/content-architecture.md
- Billing is intentionally removed for now.
- Replay is protected and requires login.
- Blog and docs are planned public surfaces, but the implementation choice is still open.