Files
template-react/docs/SURFACES.md
T
pastilhasandClaude Opus 5 c08dab4243 Initial commit: Bun · Hono · React SPA · Postgres/Drizzle template
The starting point for an app with a React frontend. Same server, env,
database and API skeleton as template-htmx — the two differ only in what is
served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top.

Carried from a working project rather than designed in the abstract, and
carrying the lessons that cost it time:

- The dev script states NODE_ENV=development rather than inheriting it. React
  resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development
  and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit
  production while transforming for development and the app dies at first
  paint. Verified: the dev bundle resolves the development runtime and
  contains no void-0 stub.
- Asset URLs in components are relative and <base href="/"> is in the HTML,
  so a path-prefix proxy can rewrite one attribute and every asset follows. A
  test enforces it.
- Routing is in the hash, because the app may be mounted under a prefix it
  only learns at runtime and a hash means the same thing at either mount.
- index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because
  Bun's HTML bundler fails the build on a root-relative favicon href.
- APP_ENV failing closed, env files before the ambient environment, the
  database enforced by name, one Postgres client per process — as in the
  htmx template.

The database has no tables. The App is a shell with one tab and no features,
named as a placeholder. Renaming `app` is the first step and the README lists
every place it appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:02:52 +00:00

90 lines
4.0 KiB
Markdown

# Apps and surfaces
Two surfaces, **one process, one port, one database**:
```
example.com/ ──┤ apps/frontend │ React SPA, talks to /api
example.com/api/thing ──┤ servers/api │ serializers -> JSON
└───────┬─────────┘
│
┌───────┴─────────┐
│ appdb │ queries
└─────────────────┘
```
Here the SPA is a client of the API like any other, which is the point of a SPA. The rule below is
about the server side: the API is the only door to the data, and it must never return HTML.
## HTML and JSON are siblings, not layers
## The rule
**Both surfaces call the query layer. Neither calls the other.**
### The web app does not call its own API
It runs in the same process. Going out over HTTP to talk to ourselves buys a round trip, a
serialisation, a deserialisation, and a second way for two screens to disagree. It also makes every
page render depend on the server being able to reach itself, which is a failure mode nobody wants to
debug.
Import the query function. That is what it is for.
### The API does not return HTML
Not even for htmx. It is tempting — the fragment is right there — but the API is a documented,
external contract consumed by things that are not this website, and htmx's needs are not theirs. A
fragment that becomes a de facto API cannot be changed without breaking someone, and you find out
when they tell you.
Fragments belong to `apps/web` and are free to change on any afternoon, precisely because nothing
outside this repo depends on them.
## What they share, and what they must not
| | |
|---|---|
| **Shared** | query functions, domain rules, database types |
| **Not shared** | serialisation, error presentation, validation of transport, URL shape |
The API has serializers because JSON needs a stable, named, snake_case shape. The web app has
templates because HTML needs markup. Trying to make one produce the other gives you a serializer with
markup in it.
## They are allowed to behave differently
This is the part that feels wrong and is right.
The two surfaces have different callers, and the same event can deserve different answers:
| Situation | `/api` | the site |
|---|---|---|
| Creating something whose name is taken | **409** with an error envelope | show the list, with the existing one in it |
| A malformed id | **404**, in the envelope | render the page's empty state |
| Something failed | error object with a `type` a program can branch on | a sentence a person can read |
A program needs to distinguish "already exists" from "created". A human clicking Create wants the
thing they asked for to be on screen; being shown an error for a collection that exists is a worse
answer than showing it to them.
**What must never diverge is the query layer.** If the two surfaces can produce different *data* for
the same request, that is a bug. If they present the same data differently, that is the point.
## Naming
`/thing` and `/api/thing` stay parallel. `/api` is mounted before the site's catch-all in
`src/server.ts`, so it is resolved first and the site never swallows an API path.
Within the site, pages and partials are **different URLs**, never the same URL switching on
`HX-Request` — see `docs/WEB.md`. Combined with the `/api` split, that gives three URL spaces with one
representation each, and every one of them is cacheable without a `Vary` header.
## Adding a resource
1. `app_db/src/<feature>/` — schema and queries. Both surfaces will import these.
2. `servers/api/<feature>.ts` — a router, plus a serializer in the shared serializer module.
3. `apps/web/<feature>/partials.tsx` and a page — both calling the same queries.
4. Mount the API router in `servers/hono.ts`; add the site's routes in `apps/web/index.tsx`.
If step 3 finds itself wanting something step 2 already computed, that logic belongs in step 1.