↗+

Kita · Couple Diary

4 min read

#TanStack Start#React#Cloudflare Workers#D1#R2#TypeScript

The Problem

Most journaling apps are built for one person, and most “shared” apps are built for an audience — feeds, followers, likes. What my partner and I actually wanted was much smaller and much more private: one diary, two people. A place to drop a photo, a sticky note, or a few sentences about a specific day, and for the other person to wander through it later.

The existing options didn’t fit. Shared note apps feel like work documents. Social apps demand an audience. And almost everything wants an email address, an OAuth flow, and a subscription before you can even write your first entry.

The Solution

I built Kita — “us” in Indonesian — a private, two-person diary. Every calendar date has its own freeform Excalidraw canvas where we can place sticky notes, text, and photos anywhere, like a FigJam board scoped to “today.” There’s a month calendar with dots marking days that have content, and a settings page for account management and inviting a partner.

The whole thing is deliberately tiny in scope: one diary, exactly two seats, no sharing, no discovery. Signup needs nothing but a username — no email, no OAuth — and the partner joins through a one-time invite code.

Key Features

Date-Based Freeform Canvas

  • One board per day: every date gets its own canvas, created lazily on first edit — viewing an empty day persists nothing
  • Freeform layout: sticky-note-style text and photos, draggable, resizable, and rotatable anywhere on the canvas
  • Autosave: no save button; writes are debounced and flushed when the tab is closed or hidden
  • Content calendar: a month view with dots showing which days have entries

Photos

  • Couple-scoped storage: photos live in R2 under a per-couple namespace, never publicly listable
  • Drag-and-drop: drop or paste an image and it lands on the canvas as a photo shape
  • Local/dev parity: local disk mirror in dev, R2 in production, identical key layout

Auth Without Friction

  • Username-only signup: no email, no OAuth; the password starts as the username and Settings prompts a change
  • One-time invite codes: single-use, 10-minute TTL, redeemable only while the couple has an active session — second devices just sign in
  • Hard caps: exactly 2 accounts per diary, enforced at the database level with a unique seat constraint
  • Brute-force guards: PBKDF2-SHA256 (210k iterations, WebCrypto-only) plus fixed-window rate limits per IP and per account, with login errors that never reveal whether a username exists

Technical Stack

  • Framework: TanStack Start with file-based routes and type-safe server functions
  • Frontend: React 19, Tailwind CSS v4, shadcn UI, Excalidraw for the canvas
  • Database: SQLite everywhere — better-sqlite3 locally, Cloudflare D1 in production, with Drizzle ORM
  • Storage: Cloudflare R2 for photos
  • Infrastructure: Cloudflare Workers, provisioned as code with alchemy (Worker + D1 + R2 in one deploy)
  • Testing: Vitest for pure logic, plus a 41-assertion auth e2e suite that drives real HTTP

Design Philosophy

The constraint that shaped everything was “exactly two people.” It simplified the data model (a couple is just two seats, enforced by the database), the auth model (an invite code admits exactly one partner, past any cap), and the cost model (D1 + R2 + Workers stay comfortably in free tiers at this scale).

I also chose boring, portable primitives deliberately. The password hashing runs on pure WebCrypto so the same code works on Workers, Node, and the browser. Local dev and production share one SQLite schema and one photo-key layout, so nothing about deploying is a surprise.

Challenges & Learnings

Deploying SQLite migrations to D1 through alchemy came with sharp edges I learned the hard way: migration folders must be timestamp-prefixed or they’re silently skipped, and D1 refuses to drop a column while an index still points at it.

Designing the auth without an email address forced me to think harder about abuse paths than a typical “add Auth0” setup would — rate limits have to stack per IP and per account so distributed guessing still exhausts, and error messages have to be uniform.

The most valuable lesson was scoping. Deciding what the app would never do — no multiplayer sync in v1, no sharing, no rich media — made every other decision obvious, and the whole thing went from first commit to shipped in about a week.