The SPA lives inside the Worker that serves it — same directory, same build, same deploy, same origin.
No second project. No CORS. No second deploy.
What that buys, concretely
Adding React is how you get here, building and deploying is what ships it, and keeping routes in sync is what stops the asset router and your Worker disagreeing.
Cookie sessions work with no header work. The browser sends the session cookie on same-origin requests: no token juggling, no refresh logic in your UI, and no token in browser storage.
That last one is the security payoff. A token in storage is readable by any script that ends up on the page, and the whole point of the cookie path is that the credential is not reachable from JavaScript at all.
CSRF protection is strict and invisible. Cookie mode means CSRF protection, always — and same-origin deployment is what makes the check both.
One thing to deploy. One artifact, one version, one rollback. The client and the API cannot be out of step with each other, because they ship as one thing.
Dev is one process, not two
A Worker with a front end contributes one process.
One dev server runs the SPA and the Worker together. Your components hot-reload; your Worker code runs in the real runtime, in the same server, at the same address.
HMR against real bindings — not mocks. Your Worker talks to real local D1 and KV while the client reloads in front of it.
Shared local state at the project root, which is the same store migrate and seed use. Migrate on one side and the running dev server sees it.
Open the printed URL and both halves are there: the root is the SPA, and the API paths are the Worker.
The asset router decides per path
not_found_handling is set to serve the SPA shell, and run_worker_first is an explicit allowlist derived from that Worker’s composed route table — never a blanket setting, and never a guessed prefix.
flowchart LR
R["A request on your origin"] --> M{"Matches an entry in<br/>run_worker_first?"}
M -- yes --> WK["Your Worker"]
M -- no --> SH["The SPA shell"]
WK -.-> N1["Capability base paths<br/>and the health route"]
SH -.-> N2["Every other path,<br/>whatever the method"]
Pithy’s routes sit at capability base paths plus the health route. Nothing lives under a conventional API prefix, and an allowlist that assumed one would hand the health route the SPA shell.
Three derivation rules:
Every entry is emitted in two forms, the bare path and its glob — because a glob does not match the bare path.
Never a bare-prefix glob. A prefix glob also captures a longer word starting the same way; the pair captures the route table exactly.
The table is taken once per environment, and the allowlist is the union — because a Worker composes differently per environment.
The array form is the mechanism
The array form is what enables static routing, and that is the whole thing.
With it, the fallback applies to every request no worker-first pattern matched — so a listed route always reaches the Worker and an unlisted one always gets the shell, whatever the method.
Without it, the asset worker overrides the fallback for every request that is not a navigation — which sends fetches and command-line requests to the Worker and still hands the shell to a magic-link click or an OAuth callback.
Both halves of that trade are silent 200s. The list is the half that can be checked.
Keeping it right
pithy ui sync --check --worker apiThe route table changes whenever a route is mounted — by adding a capability, by removing one, and by you writing one into your own app capability.
That last one runs no command, which is how the list goes stale without anybody touching it.
A shadowed route answers a 200 of the SPA shell with your handler never invoked, and no test suite of yours sees it — tests call handlers directly, so the asset router is never in the picture.
So the check exits non-zero and belongs in CI.
A route the allowlist cannot express — a catch-all, or one mounted at the root — is never reported, because a check that flagged those would mark every project drifted forever.
Dev’s same-origin resolution
In dev, same origin is the address the run is actually serving on.
Your configured origin holds where you deploy, and where you deploy is HTTPS. Local dev has no TLS and its port is assigned per Worker per run — so it is the one address no config file can hold.
A dev composition therefore ignores the configured origin and resolves whatever host the request arrived at. That is what the browser is at, so the check passes with nothing added to your trusted origins, and it follows the port when a second Worker shifts the allocation.
It is not a wildcard. A request from a neighboring Worker in the same dev run is refused like any other. The gate is one condition on the environment alone, so staging and production resolve the configured origin verbatim.
The session cookie’s name comes off the same resolution — the secure-prefixed name over HTTPS, the plain one in dev. Both are computed from one place; they cannot disagree.
Your own routes wear the same gate
app.post("/organizations", requireAuth(), requireSameOrigin(), handler);It takes no arguments. The auth capability publishes the check already bound to the origins it resolved, so this is the policy auth is enforcing rather than a second copy that can drift.
There is no origin list to pass, which is why there is no wrong one.
Bearer is still there
Short-lived access tokens, refresh in secure device storage, deep links for OAuth. Fully supported by the same capability, on the same routes.
It is documented rather than scaffolded, because this stub is a browser app — and in a browser, cookies are the right answer.