SWE Case Study

Legado Genealogy

A diaspora-genealogy platform on Next.js 15 App Router + tRPC. The story isn't 'I shipped it' — it's 'I shipped, then I hardened the auth boundary.'

Next.js 15 App RoutertRPC 11PrismaThree.jsAES-256-GCM
Scroll to explore

Snapshot

At a Glance

Role

Engineer (Contract)

Sole developer, external client

Duration

~3 mo

Groves platform v1.0–v1.3 (2026)

Scope

59 plans

16 phases shipped

Surface

25+ tRPC routers

3 locales (en/es/fr)

01 / Context

A contract build for a domain that is not generic

This was a contract engagement building Groves — a DIY genealogy tooling platform serving the diaspora-genealogy domain — for an external client. The product is named, public, and live; the engineering surface is mine to talk about, the business is theirs. The consulting surface (intake forms, payment + prefill flows, public site) is live in production; the genealogy tools themselves (person CRUD, GEDCOM, 2D/3D tree visualization) are in active development.

Diaspora genealogy carries unusually rich data: persons, relationships, unions, enslavements, migrations, emancipations, DNA records, name variants. Around twenty-five domain models, each with its own tRPC router. Sensitive fields — names, vital records, ancestral testimonies — live behind AES-256-GCM field-level encryption. The schema reads less like a CRUD app and more like a small archival database.

The story this case study tells is about the second half of the engagement. The v1.0 ship was the easy part: scaffolding a Next.js 15 App Router project, wiring tRPC, getting Clerk in front of it, getting Prisma talking to Postgres, putting the consulting flows in front of real users. The hard half was v1.1 through v1.3 — closing auth holes that had survived the launch on those consulting flows, and tightening transaction boundaries that had originally been written as sequential calls. That is where the real engineering happened.

02 / What I Built

The system shape

Next.js 15.4 App Router (Server Components + Client Islands, not the older Pages Router), TypeScript strict, tRPC 11.4 fronting twenty-five-plus domain routers, Clerk 6.36 for auth and RBAC via session claims, Prisma 6.10 over PostgreSQL. Three.js 0.183 + React Three Fiber 9.5 render the 3D family-tree grove view. d3-hierarchy and d3-shape draw the 2D layouts. Both views read the same Person graph from the same Postgres tables.

Legado Genealogy architecture: Next.js 15 App Router with Clerk middleware, tRPC procedure hierarchy (base → public → private → admin), 25+ domain routers, AES-256-GCM crypto pipeline, Prisma Postgres, Three.js 3D grove

The procedure hierarchy is where most of the design lives. trpc/init.ts defines four nested layers: a baseProcedure that attaches the Prisma client and the request context, a publicProcedure for endpoints that anyone can hit, a privateProcedure that requires a signed-in Clerk user, and an adminProcedure that additionally checks a role claim. Each layer composes the previous one and adds exactly one guard. The v1.0 surface mostly sat on the public layer — fine for the read paths, dangerous everywhere else.

The visualization layer is dual-rendered. The 3D grove uses React Three Fiber as a thin React wrapper over a single global Three.js scene; the 2D family-tree layouts use d3-hierarchy to compute node positions and d3-shape to draw the edges. Both views consume the same Person graph and the same relationship rows out of Postgres, then project into different geometries. It cost more to build two renderers than to pick one, but the 2D view answers different questions — siblings-on-a-line, ancestor-tree zooming — than the 3D view answers, and trying to satisfy both with one renderer would have been worse engineering.

The i18n surface is middleware-driven URL-prefix routing for English, Spanish, and French. Two distinct translation patterns exist on purpose: the server side reads an x-locale request header and calls getTranslation(locale), while the client side uses a useTranslation() hook that derives the locale from usePathname(). They compile to the same translation tables but the wiring differs. Future maintainers have to know which environment they are in before they pick a hook.

Other engineering on Legado

AES-256-GCM field-level encryption with bulk decryptPersonRecord pipeline (envelope-shaped {data, iv, tag}, transparent legacy-plaintext handling)Diaspora-specific schema as first-class entities: enslavement, plantation, migration, emancipation, DNA, residence, occupation, military serviceHMAC-SHA256 signed tokens for payment + prefill links (purpose-specific secrets, base64url payload, expiry checked on decode)Upstash sliding-window rate limiting (5 req/min/IP) on 6 public mutationsDB safety scripts blocking production-URL Prisma migrations across 7 hosted-Postgres providersAuth-enforcement test suite (8/8) verifying sensitive mutations never sit on publicProcedureRSC security boundary: Server Component orchestrator with client islands — server-only code never bundled to the browser

03 / Hard Parts

The two migrations that were actually hard

publicProcedure → adminProcedure migration without breaking public payment flows (v1.3 Phase 15)

Three procedures — markDepositPaid, sendPaymentLink, and createPrefillToken — had been publicProcedure since the v1.0 launch. They were quietly trusting the caller. Anyone with the procedure name and a payload could mark a deposit paid.

Lockdown required: identify every caller (server actions, client components, scheduled jobs), migrate the UI to authenticated paths, add three new auth-enforcement tests that actually exercise the unauthenticated path and assert a 401.

The tradeoff is explicit and survives in the code: decodePaymentToken and decodePrefillToken had to remain public, because the customer-facing payment page sees an unauthenticated browser. So I separated the two halves of the surface: read tokens stayed public, signed and time-bounded with HMAC-SHA256; write actions moved behind adminProcedure. The cost is shape divergence — the payment flow now has two distinct token shapes, and any future engineer touching this code has to know which is which. I documented it. I do not love it. It is correct.

Atomic submitIntake upsert via prisma.$transaction (v1.3 Phase 16)

Originally a sequential lead update followed by an intake upsert — two separate Prisma calls in a single procedure. The lead row was marked paid in call one; the intake form rows were upserted in call two. Each call was correct on its own.

The failure mode hid in the gap. A crash mid-flow — a network blip after the lead update committed but before the intake upsert ran — could leave a paid lead without their intake form data. Invisible, because the lead row looked complete and billed. The bug only surfaced later, when the consultant tried to schedule the engagement and found an empty intake row.

Fix: wrap both writes in prisma.$transaction. Either both rows write or neither does. The tradeoff is that the transaction window is now longer, which means more lock contention in theory. Mitigated in practice because submissions are serialized per user — same user can not double-submit, the intake key is the Clerk user id — so any contention is inter-user only and the lock surface is tiny.

04 / What I'd Change

The genealogy schema sprawl

Twenty-five-plus tRPC routers — one per domain model, including person, relationship, union, enslavement, migration, emancipation, dna, nameVariant — reflect the rich diaspora-specific data model. The shape is useful: each domain has its own validation rules, its own encryption fields, its own audit needs. But the boilerplate is real. Every router repeats roughly the same outline: list, get, create, update, delete, plus auth gates and the encryption decorate / undecorate pipeline.

If I started over I would extract a domain-router factory. One function, parameterized by Prisma model name and an encryption field list, that emits the standard CRUD shape with the auth layers and crypto pipeline pre-wired. Bespoke routes would still exist where the domain demanded them, but they would compose on top of the factory rather than each starting from scratch. It is the kind of abstraction that earns its keep around the fifth router and is genuinely missed by the twentieth.

Built for Legado Genealogy.