flowingkhaosSign in
Deploy your own Travolestep 3 of 7 · 0 of 14 carriedSign in to keep it
Step 3 of 7 · weight 2 · about 25 minutes

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

  1. Reads. A page such as /trips/[tripId] calls a function in lib/queries/. That function runs one SQL query through Drizzle and returns plain data. The page renders it on the server.
  2. Writes. A form posts to a server action (actions.ts beside the page). The action checks the session, validates the input, writes one row, and revalidates the page.
  3. Receipts. The expense form uploads a PDF. lib/storage.ts checks that it really is a PDF (its first bytes are %PDF-) and under 10 MB, then stores it in R2 under receipts/<expense id>.pdf. Downloads go through /api/receipts/[expenseId], which checks that the expense belongs to you before it streams the file.
  4. Exports. Both exports start from the same data: lib/report/shape.ts builds one TripReport. The Excel export writes it with exceljs. 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-http sends 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…

All 7 stepsGo to the gate

Luke Sidney writes and builds everything here, alone, in the open. lukesidney.me