The Tech Stack Behind NoteMee
We built NoteMee as a small team with strong opinions about simplicity. Those opinions extend to our technical choices. This post covers what we use, why we chose it, and the tradeoffs we've accepted.
If you're building a collaborative editor, a SaaS billing system, or a Next.js app at scale, there might be something useful here for you.
The Editor: Tiptap v3 + Yjs
The editor is the heart of NoteMee. We use Tiptap v3, which is a headless rich-text editor built on ProseMirror. Tiptap gives us a clean extension API, schema-based document validation, and first-class support for collaborative editing through Yjs.
Yjs is a CRDT (Conflict-free Replicated Data Type) implementation. The key property: multiple users can edit the same document concurrently, even offline, and their changes will merge deterministically without conflicts. There's no central authority deciding who "wins." The math guarantees convergence.
We evaluated operational transform (OT), which is what Google Docs uses, but OT requires a central server to resolve conflicts. CRDTs don't. This means our collaboration works offline by default. You can edit on a plane, close your laptop, open it on hotel wifi, and your changes merge cleanly.
The tradeoff is document size. CRDT documents carry history metadata that makes them larger than a plain JSON representation. For typical notes, this is negligible. For a 50,000-word document with hundreds of concurrent editors, it would matter. We optimize for the common case.
Collaboration Server: y-websocket
For real-time sync, we run a custom y-websocket server. When you open a note, your browser connects via WebSocket and syncs Yjs document state with the server. The server handles:
- **Document persistence.** The Yjs document is periodically flushed to the database so it survives server restarts.
- **Awareness protocol.** This is how you see other users' cursors and selections in real time. The awareness layer is ephemeral — it doesn't need persistence, just fast broadcast.
- **Connection management.** Graceful handling of reconnects, stale connections, and room cleanup when all users leave a document.
We considered hosted collaboration services, but running our own server gives us control over persistence timing, connection lifecycle, and cost. At our scale, a single Node.js process handles thousands of concurrent connections comfortably.
Framework: Next.js 16 with App Router
The frontend and API layer run on Next.js 16 using the App Router. We use Turbopack for development builds, which makes the local dev experience fast.
Server Components handle the initial page render, which means the first paint includes real content rather than a loading spinner. Client Components take over for the interactive editor. This split keeps the JavaScript bundle small for page transitions while still delivering a rich editing experience.
We use Server Actions for mutations (creating notes, updating settings, managing subscriptions). They eliminate the boilerplate of API route handlers for simple operations while still running server-side with full access to the database.
Route Handlers still exist for things that need a traditional API shape: webhook endpoints, the collaboration server health check, and image upload presigned URLs.
Database: PostgreSQL + Prisma
Our primary data store is PostgreSQL. Prisma handles schema management, migrations, and query building. The schema covers users, workspaces, notes, tasks, permissions, and billing state.
We chose PostgreSQL over alternatives because it's boring in the best way. It handles our query patterns well, has excellent tooling, and won't surprise us at 3 AM. Prisma adds type-safe queries and a migration workflow that keeps schema changes auditable.
One implementation detail: note content is stored both as a Yjs binary blob (for CRDT sync) and as rendered HTML (for search indexing and server-side rendering of the initial page load). This dual storage adds complexity but avoids having to deserialize Yjs documents on every page view.
Image Uploads: Cloudflare R2
User-uploaded images go to Cloudflare R2. R2 is S3-compatible object storage with one critical difference: zero egress fees. For a note-taking app where users embed screenshots and diagrams, egress costs on S3 would scale unpredictably. R2 makes our storage costs proportional to what's stored, not how often it's viewed.
The upload flow: the client requests a presigned URL from our API, uploads directly to R2 (bypassing our server for the heavy payload), and stores the resulting URL in the note content. This keeps our server lean and our upload speeds fast regardless of file size.
Rate Limiting: Upstash Redis
We use Upstash Redis for API rate limiting. Upstash is a serverless Redis provider, which fits our deployment model.
An important design decision: our rate limiter fails closed. If Redis is unreachable, the request is denied rather than allowed. In production, we'd rather briefly inconvenience a legitimate user than leave our API unprotected during an outage. This is an unpopular choice — most rate limiters fail open — but it's the right one for our threat model.
Rate limiting covers authentication endpoints (to prevent credential stuffing), the collaboration WebSocket handshake (to prevent resource exhaustion), and image uploads (to prevent storage abuse).
Authentication: Auth.js v5
We use Auth.js (formerly NextAuth) v5 for authentication. Sessions use hashed tokens stored in the database rather than JWTs. This means we can invalidate sessions instantly when a user changes their password or when we detect suspicious activity. JWTs would require waiting for expiry or maintaining a revocation list.
We support email/password and OAuth (Google, GitHub). The auth flow includes rate-limited login attempts, secure password hashing with bcrypt, and CSRF protection on all mutations.
Billing: Stripe Multi-Tier
Billing runs through Stripe with multiple subscription tiers. The implementation handles upgrades, downgrades, prorated billing, and cancellation with grace periods.
We built trial-abuse prevention into the billing system. When a user's trial expires, downgrade is immediate. We track trial usage by both email and payment method fingerprint to prevent the "new account, new trial" loop. This isn't foolproof — determined abusers can always find a way — but it eliminates casual abuse, which is the majority of it.
Stripe webhooks handle the async events (payment succeeded, subscription cancelled, payment failed). We process these idempotently, which is essential because Stripe can and will send duplicate webhook events.
Testing: Playwright
We use Playwright for end-to-end testing and, somewhat unusually, for generating marketing screenshots. A Playwright script logs in, navigates to specific views, and captures pixel-perfect screenshots of the app in action. This means our marketing images always reflect the real product and update automatically when the UI changes.
What We'd Change
No stack is perfect. If we were starting over:
- We'd evaluate SQLite (via Turso or LiteFS) for simpler operational overhead at our current scale.
- We'd spend more time on Yjs document compaction earlier. Large documents accumulate history that should be periodically squashed.
- We'd build the rate limiter as middleware from day one rather than retrofitting it.
But overall, this stack has served us well. It's fast to develop on, reliable in production, and simple enough that any engineer on the team can debug any part of it.
If you have questions about any of these choices, reach out — we're always happy to talk shop.