Files
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

4.0 KiB

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.