The architecture
One app, three services
Travole is one Next.js application. It talks to three outside services and nothing else:
┌─────────────────────────── Vercel ───────────────────────────┐
browser ──────▶ │ Next.js 16 (App Router) │
│ pages (server components) · server actions · route handlers│
└───────┬──────────────────────┬───────────────────────┬───────┘
│ SQL over HTTPS │ S3 API │ in-process
▼ ▼ ▼
Neon Postgres Cloudflare R2 Better Auth
(trips, expenses, (receipt PDFs, (sessions, users,
auth tables) one key per expense) stored in Neon)
There is no separate backend, no queue and no cache. A page is a server component that reads the database directly. A form is a server action that writes to it.
How a request flows
- Reads. A page such as
/trips/[tripId]calls a function inlib/queries/. That function runs one SQL query through Drizzle and returns plain data. The page renders it on the server. - Writes. A form posts to a server action (
actions.tsbeside the page). The action checks the session, validates the input, writes one row, and revalidates the page. - Receipts. The expense form uploads a PDF.
lib/storage.tschecks that it really is a PDF (its first bytes are%PDF-) and under 10 MB, then stores it in R2 underreceipts/<expense id>.pdf. Downloads go through/api/receipts/[expenseId], which checks that the expense belongs to you before it streams the file. - Exports. Both exports start from the same data:
lib/report/shape.tsbuilds oneTripReport. The Excel export writes it withexceljs. The PDF export renders the same React components the trip page uses to HTML, then prints that HTML with a headless Chromium.
Why each piece is there
- One report shape, two renderers. The screen, the Excel file and the PDF can never disagree,
because they all come from
buildTripReport(). - The HTTP database driver.
neon-httpsends each query as one HTTPS request. It suits serverless functions, which cannot hold a connection pool open. The trade-off: it has no transactions. So every guarantee in the auth code is one SQL statement, whose atomicity the database owns. - Money in cents. Amounts are integers. The exchange rate is a fixed-precision decimal. Nothing is ever a floating-point number.
- Countries from ISO codes. The 230 destinations are stored as two-letter codes. The French
name comes from the browser’s
Intl.DisplayNames, and the flag comes from the two letters themselves. There is no icon set and no translation file.
The data model
user ─┬─< trip ─< expense >─ country ── country_tax_rate
│ >─ category
└─ session, account, verification (Better Auth)
auth_bootstrap one row, ever: "the first admin exists"
invitation one address, one use, an expiry
Deleting a trip deletes its expenses. A country or a category that an expense uses cannot be deleted; you disable it instead.
The gate · a reading check closes this stepweight 2 of Σw 14
Check what you read in “The architecture”
The questions are public; scoring them needs an account. They were generated once for this step, and the key never leaves the server.
Loading the check…