Architecture
Understand the boundaries between the web app, API, contracts, providers, and data.
Architecture
The foundation separates the browser, API, contracts, persistence, and provider adapters so a builder or coding agent can change product behavior without inventing a second architecture.
flowchart LR
U[Browser] -->|static assets| W[Native cloud, Vercel, or Cloudflare\nReact SPA]
U -->|HTTPS + Better Auth cookie| A[Railway, Dokploy, AWS, GCP, or Azure\nBun + Hono API]
W -. typed Hono RPC .-> A
A -->|bun:sql| P[(Postgres 18)]
A -->|Better Auth pg adapter| P
A -->|normalized provider contract| B[Selected billing provider]
A -->|transactional delivery| E[Resend]
A -->|presigned upload + metadata| R[R2, Amazon S3, or GCS]
A -->|optional envelope tunnel| S[Sentry]The supported cloud defaults are cost-first and promote explicitly to HA. Each environment selects one backend cloud and one static frontend target. The application is a Vite SPA, not a server-rendered Next.js application, and there is deliberately no generic worker or queue in v1.
Workspace boundaries
| Path | Owns |
|---|---|
apps/web | React shell, React Router, TanStack Query, Zustand client state, CSS Modules, Zag.js, PWA |
apps/server | Hono routes, services, SQL repositories, Better Auth, migrations, provider orchestration |
apps/docs | Separate Fumadocs and Next.js documentation application |
packages/contracts | Zod wire schemas and inferred request/response types |
packages/billing | Provider-neutral billing types and Polar, Stripe, and Dodo adapters |
packages/email | Typed React Email sources and deterministic rendered HTML |
packages/create-app | Buyer copy, package/brand rewrite, secret/env generation, optional Git initialization |
packages/agent-eval | PRD outcome checkpoints, intervention records, boundary checks, evidence manifests |
packages/agent-context | Optional read-only local MCP documentation and planning context |
packages/tsconfig | Shared compiler policy |
infra | OpenTofu bootstrap, cloud modules, executable roots, locks, and tests |
e2e | Playwright browser flows and deployed-boundary smoke |
scripts and .github | Release, deployment, CI, and verification orchestration |
landing/ is the source seller's commercial site. It is outside the root workspace and is never
copied into a generated buyer product. operations/ is also seller-only and excluded from buyer
output.
Server domain shape
Every product domain uses explicit layers:
contracts schema
↓
routes.ts HTTP mapping, session, validation, response status
↓
service.ts policy, provider-neutral orchestration, transactions
↓
repository.ts parameterized SQL and persistence mapping
↓
PostgresRoutes do not contain SQL. Repositories do not know Hono. Services do not accept raw provider
payloads after adapter normalization. Wire schemas live once in packages/contracts; database rows
are mapped rather than exposed accidentally.
The optional Add a domain end-to-end playbook provides a tested implementation route. You may use another approach, but preserve the ownership, failure, migration, contract, and test rules above rather than copying a nearby file mechanically.
Identity and authorization
Better Auth owns /auth/*, password hashing, verification, password reset, magic links, Google
OAuth, cookies, and sessions. sessionAuth resolves the cookie through auth.api.getSession() and
places only session.user.id into Hono context.
Product authorization is separate from authentication:
- the route requires a verified session;
- the repository receives the current
userId; - private reads and writes include the ownership predicate in SQL;
- a missing or cross-owner private record returns the same 404 shape when existence must remain private.
There is no application access JWT, refresh token, local-storage token, parallel user key, or generic 403 authorization helper.
See Authentication and Add an authenticated route.
Billing and entitlement flow
The browser may request checkout, but it never grants access:
sequenceDiagram
participant Browser
participant API
participant Provider
participant DB as Postgres
Browser->>API: create checkout (session cookie + plan)
API->>Provider: product/price ID + external user ID
Provider-->>Browser: hosted checkout
Provider->>API: signed webhook
API->>API: verify and normalize in adapter
API->>DB: claim event + mutate subscription in one transaction
API->>DB: derive paid entitlement
Browser->>API: fetch billing status
API-->>Browser: server-derived accessSubscription/customer rows are provider-neutral. The event claim and state mutation are one
transaction; duplicate events are ignored and older provider timestamps cannot restore stale access.
Trial, active, past-due, canceled-with-time-remaining, and expired states have explicit policy in
domains/billing/entitlements.ts.
Account deletion is blocked while the local provider subscription can renew or an already-paid period remains. Deleting application data does not cancel a remote provider subscription.
Email and local development
React Email source templates are rendered into the server's required HTML inventory. The server preloads all templates before serving traffic. Resend handles production delivery; when Resend is unset in development/test, the complete email and action URL are logged.
The fallback makes authentication deterministic without vendor keys. Production environment validation requires Resend so reset, verification, and magic-link URLs cannot leak through the local log path.
Upload flow
The only included upload purpose is an owner-bound profile image:
- validate purpose, MIME allowlist, declared size, target ownership, and entitlement;
- persist a pending claim and issue a 15-minute provider presigned
PUT; - confirm against actual provider metadata and the pinned object version;
- atomically consume the claim or mark it failed and best-effort delete the object.
The path prevents URL-only ownership, replayed confirmation, and trusting the browser's claimed size. Storage remains optional locally. See Add an upload type.
Configuration boundary
apps/server/src/config/env.ts is the sole server environment reader. Zod validates deployment
strings, converts them into the typed Config, and enforces atomic optional integrations. Domain
code consumes config(); it must not read process.env ad hoc.
Only explicitly public web values use VITE_*. Never place provider tokens, auth secrets, webhook
secrets, database URLs, or private DSNs in the web build.
Runtime and deployment lifecycle
The server startup sequence is:
- validate environment;
- connect to Postgres;
- apply pending dbmate migrations;
- register application events;
- locate and preload every required email template;
- bind the Bun server.
The compiled binary still requires external migration SQL and rendered templates. The Docker image copies both. Missing required resources fail startup rather than producing a delayed auth or billing failure.
/health is liveness. /ready queries the initialized database and gates Railway or Dokploy
traffic. See Deployment.
State ownership
- TanStack Query owns browser server-state caching and invalidation.
- Zustand owns deliberate client-only state, not a copy of the API cache.
- React Router owns route composition and protected navigation.
- Postgres is the application system of record.
- dbmate is the sole schema/migration history.
- Better Auth is the sole session authority.
- Provider adapters own raw external payloads and SDK types.
- Zod contracts own public request and response shapes.
Deliberate limits
The v1 contract excludes multiple frontend frameworks, mobile, enterprise multi-tenancy, seat billing, admin, i18n, a generic job queue, a second billing provider, and a broad infrastructure matrix. These are not missing toggles; adding one requires a validated buyer job and a complete support contract.
Vite+ 0.x and TypeScript 7 are pinned early-adopter dependencies. Their operational rules and package-native escape hatches are in Setup.