Skip to content
Methods and decisions

DR-001

From Express + MongoDB to one Next.js app on Turso

Status: Accepted · October 2026 · Author: Rin Huang

Decision

Rebuild Project Cradle as a single Next.js 16 (App Router) application on SQLite through libSQL and Drizzle ORM, with Turso as the production database. The 2022 code stays untouched in legacy/, and the 2022 rules are ported as pure functions that parity tests check against the preserved files.

Context

In 2022 Cradle was two repositories: cradle-octo, an Express 4 + TypeScript API on Mongoose 6 and a MongoDB Atlas cluster, and cradle-waddle, a Next.js 12 + Chakra UI front end. Work stopped in March 2022. By 2026 the Atlas cluster no longer existed, the API had never been deployed, and several parts had never worked: server-side sessions, the mail sender, and the login lookup (it searched userName instead of username). The revival had to run on Vercel's free tier, cost nothing to keep alive, and stay faithful to the rules that were actually written.

Options considered

  1. Resurrect as it was. Express on a small server plus a free MongoDB Atlas cluster. Most faithful to the stack, but two deployables, a server to keep running, and a database that sleeps on the free tier.
  2. Next.js with MongoDB. Keep the Mongoose models behind Next.js route handlers. One deployable, but the quota and one-use-code rules would still depend on application-level checks.
  3. Next.js with Postgres (Neon or Supabase). Strong constraints and transactions, but a hosted service for local development and tests, and no single file to hand someone as "the dataset".
  4. Next.js with SQLite / libSQL, Turso in production (chosen).

Why

  • The rules are relational. "A code is used at most once", "a member holds at most two codes" and "everyone has exactly one inviter" are constraints. In SQL the quota check and the insert become one statement and the one-use rule a conditional update (DR-002), so they hold under concurrency without a lock service.
  • The dataset is a file. web/data/seed.db is built deterministically from seed.ts and committed. Anyone can open it in an SQLite browser, and the tests, the local app and a fresh Turso database all start from the same bytes.
  • Tests need no services. Vitest runs the real migrations and queries on a temporary SQLite file, in CI and on a laptop.
  • One deployable. Server actions and route handlers replace the Express API; the five 2022 endpoints survive as JSON routes with the same { status, message, csc } replies.

What happened

  • Parity tests read the preserved 2022 files directly (status-code tables, the code alphabet, the form limits) and fail if the port drifts from them.
  • The known 2022 defects (listed in legacy/README.md) were fixed rather than ported, and each fix is called out in the README so the revival doesn't claim the original worked better than it did.
  • The first deployment ran Vercel functions in the default US region while the Turso database was in Tokyo. Every page makes several queries, so each paid several trans-Pacific round trips. Functions now run in hnd1 next to the database (commit e55ad05). I didn't measure page latency before and after the move, so I can't say how much it helped; the reasoning is sound, but it is reasoning, not a measurement.
  • So far the demo runs within Turso's free tier. Without DATABASE_URL, the app falls back to a copy of the seed in /tmp and says so on every page.

What I'd change

  • Measure before deciding: p50 and p95 page latency with bootstrap intervals for both regions, from a fixed set of pages, rather than relying on the round-trip argument.
  • Try libSQL embedded replicas, which would make reads local to the function and remove most of the region question.
  • Write the Mongoose-to-SQL field mapping down as a table in the docs. Today it lives only in comments in web/src/db/schema.ts.