Fintech ledger on React Router v8, Postgres (Neon) and Better Auth
Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify
session validation runs in server components and route handlers, not at the edge
What you're getting
React Router v8 (framework mode) — SSR, config/file routes under app/, loaders/actions, and resource routes for API endpoints.
Postgres on Neon via Drizzle ORM and the postgres-js driver.
Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).
Double-entry fintech ledger — chart of accounts, immutable journal entries, and debit/credit lines with integer-cent amounts.
Setup
bun add react-router react react-dom drizzle-orm postgres better-authDATABASE_URLNeon pooled (-pooler) connection stringBETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`BETTER_AUTH_URLyour app's base URLApply the schema with bunx drizzle-kit push
Initialization
Database client
Double-entry ledger schema: accounts, journal entries & lines
Chart of accountsper-owner accounts typed to one of five standard categories (asset/liability/equity/revenue/expense)
Journal entriesimmutable header rows tying a description and posted timestamp to a creator
Debit/credit lines & balancesthe individual debit/credit lines linking each journal entry to an account with an integer-cent amount
Deploy targets
Decisions and compatibility
Framework mode (not data/library mode): routes live under app/, declared in app/routes.ts. API endpoints are resource routes (a route module exporting loader/action but no default component).
prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.
Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.
Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.
The accounts table constrains type to the five canonical categories (asset/liability/equity/revenue/expense) via a DB CHECK — adding a new account class requires a migration, not just app code.
Debit/credit balance (sum of debits == sum of credits per entry) is enforced in application logic, not at the DB level; the schema's CHECK only guards direction values ('debit'/'credit'), so the invariant can be violated if bypassed.