Interface
The interface is one Expo app that ships to Android, iOS, and the web from a single codebase. Expo Router owns navigation, React Query owns server state, and the generated Connect client owns the wire. The package is called the interface everywhere, because it is the human side of the system rather than a “front” to the backend.
Neither the word nor the framework is the load-bearing part. What the stack’s core guarantees is that any client, in any language Connect or gRPC-Web reaches, starts from generated code that already knows every resource, RPC, and field the service offers. Expo is the validated implementation because one codebase ships to Android, iOS, and the web; the shape documented here — typed wrappers over the generated client, a server-state cache, screens on top — transfers to other frameworks, and the skills carry a language-neutral version of it for projects that choose differently.
The bootstrap skill scaffolds this blueprint, on either backend, with a working example screen wired to the example resource.
Structure
app/ Expo Router route files, thin by rule
notes/index.tsx re-exports a screen, nothing else
src/
screens/<area>/ the real screen components
components/ domain components + primitives + shells
data/
api/ typed client wrappers, one module per resource
queries/ useQuery hooks + the query-key factory
mutations/ useMutation hooks with invalidation
queryClient.ts one QueryClient, tuned defaults
state/ app-level contexts (mode, auth)
styles/ design tokens: colors, type, spacing, breakpoints
Route files re-export screens so navigation structure and screen implementation evolve independently. When one URL must render differently by app mode, the route file switches on the mode and renders the right screen; the URL stays stable and nothing redirects.
The data layer
Three tiers, one module per resource in each:
- api wraps the generated client:
client.createNote(...), converting proto messages to plain view types at the boundary (timestamps to ISO strings, enums to unions). This mirrors the backend adapter, one shape translation on each side of the wire. - queries are
useQueryhooks keyed by a central factory (queryKeys.notes(filters),queryKeys.note(id)). List keys and item keys are distinct so invalidation can be precise. - mutations are
useMutationhooks that invalidate both the item key and the list key on success. Optimistic updates are an opt-in per mutation, added when a screen’s feel demands it.
A second walked shape collapses the first two tiers into generated code:
@connectrpc/connect-query derives typed useQuery/useMutation hooks
straight from the service descriptor, with keys from
createConnectQueryKey, so a screen calls useQuery(listNotes) and no
one hand-writes an api module. It is the right choice when the resource
count is large and the view types are the proto types; the hand-written
api tier earns its keep when screens want narrower view models than the
wire carries. Either way, generated protos are deep-imported and never
re-exported through a barrel file, because Metro does not tree-shake and a
barrel over the contract drags every message into every bundle.
A persisted query cache (@tanstack/react-query-persist-client for an
instant cold start) cannot hold protobuf-es messages as they are: every
int64 is a bigint, including Timestamp.seconds, and JSON.stringify
throws on it; bytes is a Uint8Array that serializes as an object of
indexes. The persister has to tag both and reverse the tags on restore.
Neither library documents this; the first persisted list finds it.
The Connect client is a singleton (src/data/api/client.ts):
createConnectTransport({ baseUrl }) from @connectrpc/connect-web plus
createClient(AppService, transport). connect-web v2 dropped the
credentials option, so a client whose session is a cookie passes a
fetch override that sets credentials: "include" on every request.
A transport note the upstream docs will not give you: connect-es
officially targets Node and browsers; React Native is unsupported
territory. The stack uses the web transport on native and keeps the
contract unary, which is why it works. The old reason (React Native could
not stream a fetch body) is gone since Expo SDK 52’s expo/fetch, so
server-streaming is no longer forbidden; it is a stack-wide decision per
project, made once, with the native fetch story checked first. No service
has walked it yet; the first candidate is the server-push stream the Go
document describes.
Sessions and environment
Two session shapes have been walked, and they are the client halves of the two auth shapes in the Go document.
A cookie from the backend’s own login. The backend is the OIDC client
and sets an HttpOnly session cookie; the interface holds no token, sends
credentials on every call, and treats Unauthenticated as “show the
sign-in button”. Nothing to cache, nothing to refresh. It needs the web
app on the backend’s origin, which the deploy section makes the rule.
Bearer access tokens with a refresh token. One interceptor attaches
Authorization: Bearer from a synchronous in-memory cache, refreshes
proactively when the token is within a minute of expiry, and on an
Unauthenticated answer refreshes once, single-flight, and retries the
unary call once. Three rules make that safe:
- Sign out only on a definitive
Unauthenticatedfrom the refresh RPC itself. A network failure,Unavailableor a 5xx keeps the session: a redeploy must never log everyone out. The classification is one function and it is the security decision. - The session RPCs are exempt.
LoginanswersUnauthenticatedfor bad credentials, so an interceptor that treats everyUnauthenticatedas “expired” would refresh and retry a failed login.Login,RefreshandLogoutbypass the retry path, and the refresh runs on a bare transport so it can never recurse into the interceptor that asked. - The refresh token has two homes. On the web it is an
HttpOnlycookie the browser sends; on native it travels in the login response body and lives in SecureStore (only the refresh token: SecureStore has a 2 KB value limit). The client is in exactly one mode at a time, and the server rejects a body token rather than falling back to a cookie.
Platform splits use Expo’s .native.ts / .web.ts suffixes, confined to
the session store, push and file pickers, and the suffix-less file has to
exist too: tsc resolves @/session/store from it, and Metro picks the
native one on a device.
EXPO_PUBLIC_* variables are inlined at build time. The API URL is a
build-time fact; changing it means rebuilding the bundle. Unset, it means
same origin, which is what production builds use.
Testing
The validated harness is jest-expo with React Native Testing Library,
because Vitest cannot render React Native components. connect-es’s
createRouterTransport runs a fake service in-process, so the session
state machine, the refresh classification and every screen can be tested
against a scripted server with nothing listening on a port; a test that
flips the fake to Unavailable mid-session and expects the session to
survive is the one that pins the sign-out rule. Testing Library 14 is
asynchronous end to end: await render(...) and await fireEvent.*, or
the next render in the file comes back empty two tests away from the
cause. End-to-end web checks run Playwright against a real deployed
server, never a mock, because the contract is what is being validated.
App chrome
When one product has two jobs, the interface renders one of two shells around the same routes: an everyday capture chrome and an authoring chrome, selected by a persisted app mode and gated by role. Both shells implement the same responsive rule: bottom tabs on compact screens, a drawer on large web and iOS. Two chromes, one route tree, one data layer.
Build and deploy
- Native builds go through EAS (APK for previews, AAB for production), from a developer machine rather than CI; the free tier’s build quota is small and store builds are rare.
- The web target is a static export (
expo export --platform web). Two places to serve it are walked, and both put it on the API’s origin: embedded in the Go binary, which serves it with a single-page fallback and hashed-asset caching; or a small static container (a Caddy file server holding the export) behind the reverse proxy, which routes the Connect prefix to the backend and everything else to the export. - Same origin is a rule, not a preference, whenever the session is a
cookie. A
SameSite=Strictcookie is the right production setting and it means a dev server onlocalhosttalking to a deployed API is cross-site: the browser neither sends nor stores the cookie, so the client signs in, reads for one access-token lifetime, and cannot refresh or resume a reload. A local stack hides this, becauselocalhost:8081tolocalhost:8090is same-site (ports do not count). A dev CORS allow-list on the backend is enough for reads and is not the session path; the web session is proven only on the hosted build. Native carries its token in the body and never meets the problem. - CI builds the export on every pull request and runs the export smoke
against it, with the Expo CLI told to stay offline (
EXPO_OFFLINE=1,EXPO_NO_TELEMETRY=1) so it does not reach for the Expo API.