How We Built Fair Fight: An Honest Build Retrospective (TanStack Start + Stripe + Clerk + Postgres)
We build Fair Fight — a mobile-first legal-education workspace for self-represented litigants and people preparing to talk with a lawyer. It helps you organize the facts of a case, understand the legal issues in plain English, and see candidate legal arguments with links to public legal sources. It is deliberately not legal advice: no representation, no filing-ready documents, no outcome guarantees.
We are a solo-founder-scale team with no marketing budget to burn, and one hard rule that shaped the architecture: never claim a feature works until it is actually verified end-to-end. This post is the honest retrospective — the stack, the fail-closed design decisions, and the lessons that cost us real time.
The stack
- TanStack Start (file-based routing + server functions) with React 19, Vite, and Tailwind 4
-
Clerk for authentication (
@clerk/tanstack-react-start,@clerk/backend) - Neon serverless Postgres (HTTP driver) for persistence
- Stripe Checkout for a one-time, per-case purchase (Pro Case Analysis, $99)
- Gemini + Groq for the AI candidate-argument and document tools
- First-party analytics — no ad-tech scripts
Routes are plain files under src/routes/: /, /dashboard, /cases/$caseId, /learn/$slug, plus API handlers under src/routes/api/. Everything below is a lesson from running it and breaking it — not from a design doc.
Lesson 1 — TanStack Start API routes only mount if you register them
This one bit us twice. In TanStack Start, an API handler in a route file is not mounted just because it exists:
// src/routes/api/user/export-data.ts
export async function POST({ request }: { request: Request }) { ... }
Unit tests that import and call POST(request) directly pass — the handler logic is genuinely correct. But in a real deployment the route is never mounted and returns 404. TanStack Start only wires the server handler when the route file declares it:
export const Route = createFileRoute("/api/user/export-data")({
server: { handlers: { POST: ({ request }) => POST({ request }) } },
});
We shipped a self-serve export/delete endpoint this way and only caught it during live end-to-end prep. The permanent fix was two-fold: a route-level smoke test that hits the mounted route (not the bare handler), and a code convention that every src/routes/api/* file ends with a createFileRoute(...) mounting block — the same live-verified pattern used for /api/track and /api/stripe/webhook. If your test suite never exercises the registration, green tests mean less than you think.
Lesson 2 — Stripe webhooks: signature-first, fail-closed
The webhook is the entitlement path: it records the durable "this user paid for this case" row. Getting it wrong means granting paid access to the wrong case — or to nobody. Our rules, in order:
-
Signature first, before any Stripe client call or database work. We read the
stripe-signatureheader; missing →400 {"error": "No signature"}; invalid → 400. Nothing else runs. We verified this on the live route by POSTing a body with no signature and confirming the expected 400 — failure behavior is part of the spec. -
Verify async.
stripe.webhooks.constructEventAsync(...). The syncconstructEventuses aSubtleCryptoProviderthat throws in Bun ("cannot be used in a synchronous context"). Use the async variant and your webhook passes on every runtime. -
Dedupe with an event-id ledger. Stripe redelivers on any non-2xx with the same event id, so a
webhook_eventsledger turns retries into no-ops. -
Exact product, or no entitlement. Entitlement is only written for a session with
amount_total = 9900(USD),mode = 'payment',payment_status = 'paid', and a line-item price id equal to the configuredSTRIPE_PRO_PRICE_ID. Anything else is rejected without writing. -
Ownership is checked server-side. The
caseIdin session metadata must belong to theuserIdin metadata — a database check, never a client check. -
Refunds revoke.
charge.refundedflips the payment row torefunded, which immediately denies access (hasCaseEntitlementonly honorsstatus = 'succeeded'), and a replayedcheckout.session.completedcannot resurrect it — the payment insert is first-write-wins (ON CONFLICT DO NOTHING).
The flow is fail-closed by construction: an expired or failed checkout session never emits checkout.session.completed, so no payment row and no entitlement are ever written for it. Only a verified, paid completion creates access.
Lesson 3 — Entitlement gates are code flags, fail-closed by default
Every customer-facing flow that touches money or sensitive data sits behind an explicit flag in one restrictedFeatures module. A gate opens only after its flow is built, deployed, and verified end-to-end. While the flag is on — or misconfigured — access is denied even if an entitlement record already exists. Existing records are preserved, never silently altered; the controlled sequence is "verify, then open." This is our honesty rule made executable: the live site literally cannot present a feature as working before the flag is cleared in a controlled deploy.
Lesson 4 — No-validator POST server functions
TanStack Start server functions support .validator(), and we tried it. A POST function compiled with a validator lost the request lifecycle and returned "Sign in required" for a session that was actually signed in — same browser, same session, while the no-validator twin authenticated fine. Our convention now: server functions without .validator(), with explicit per-field validation and sanitization inside the handler. It is more manual, but it behaves identically in dev, in unit tests, and in production — which is the property we actually care about for a paid product.
Lesson 5 — Per-case data ownership and self-serve export/delete
Legal data is sensitive, so the data model is ownership-scoped from the bottom up. Every child table (analyses, timeline entries, calendar events, evidence) joins on cases.user_id — there is no read path that crosses an ownership check.
-
Evidence files are case-owned rows in an
evidence_filestable with the payload asBYTEA NOT NULL, server-enforced limits (10 MB per file; PDF/JPG/PNG/WebP/TXT), and honest UI copy: educational tooling, not secure legal-grade evidence preservation. - Self-serve export returns the signed-in user's complete dataset as an ownership-scoped JSON document. The binary evidence bytes are deliberately never read or exported — only metadata (filename, MIME, size). Snapshot first, audit log after, so the exported document is exactly the state as of the query.
- Self-serve delete removes all of the user's rows in one transaction, writes a single audit row with per-table counts, then best-effort deletes the Clerk account. The UI requires typing the word "DELETE" before it will run.
Lesson 6 — First-party analytics: tiny, honest, privacy-respecting
Analytics is a POST /api/track endpoint that receives fire-and-forget beacons (navigator.sendBeacon), validates a handful of fields (route, random session id from sessionStorage, referrer, sanitized UTM params), inserts one row, and returns 204. No cookies, no third-party scripts, no entitlements — a dropped or invalid beacon can never affect the app or the payment path. For a paid product you want your own funnel numbers, from your own database.
The part that shaped everything: honesty as a technical requirement
None of this architecture came from a security checklist. It came from a product rule: we will not claim something works until it is verified, and we will not pretend to have users, testimonials, or results we don't have. Concretely:
- Every published footer carries the truthful disclaimer below.
- Features that weren't verified stayed gated in code, not just hidden in the UI.
- When earlier drafts overclaimed, we corrected or retracted them publicly instead of leaving them up.
- Citations are real: the AI features return candidate arguments with traceable public sources, never invented case law.
It costs more upfront. The payoff: when a real customer completes checkout, the entire path — session → signed webhook → exact-case entitlement → access — has already been exercised with test payments and verified live, so what we tell people the product does is what the product actually does.
Fair Fight provides public legal education and a paid Pro Case Analysis workspace: one-time $99 per case when payment access is enabled. It does not provide legal advice, representation, filing-ready documents, deadline guarantees, or outcome guarantees. Verify deadlines with the court or a licensed attorney.
Top comments (0)