Your api already has
a mobile client.
A mobile app is the third consumer of the api you already wrote — not a second backend, not a second set of endpoints, not a hand-written fetch layer. One command scaffolds an Expo app that speaks the same typed procedures your web app does, reconnects with the same supervisor, and opens offline because a phone spends part of its life there.
# The api you already have — plus a phone.
voltro add-app mobile --template=mobile-app --to acme
cd apps/mobile && pnpm ios # Expo owns Metro, not `voltro dev`
# The same call your web screens make:
# const notes = useSubscription('notes.list')
# const create = useMutation('notes.create')
#
# What you did NOT write:
# - a mobile-only endpoint layer
# - row types copied from the server
# - a reconnect loop
# - an offline queueWhat you get when you add a mobile app.
The same hooks, on a phone
`useSubscription('notes.list')` and `useMutation('notes.create')` are the same calls your web screens make, bound to the same generated procedure map. The tag autocompletes, a typo is a compile error, and the row type is inferred from the api's own schema rather than declared twice.
One reconnect supervisor, not a mobile copy
Backoff, generation tracking and the rule that stops one signed-in user's rows appearing in the next one's screens are shared code with the web client. A second implementation would be a second place for that rule to be subtly wrong, and nothing would be watching the two for drift.
Offline is the default posture
Local-first on, optimistic writes, sync on foreground, a retry schedule, and a connection status you can put in the UI. Mobile users expect an app to open on a train; the defaults say so instead of leaving every team to rediscover them.
Persisted state that survives a cold start
Persisted stores work on a device: state is hydrated from the phone's storage before the first screen renders, then read synchronously and written through in the background. Writes to one key coalesce, so a field that saves on every keystroke costs one round trip.
Typed deep links and device registration
Declare a link pattern and its handler together, and one matcher routes every inbound URL — a universal link, a custom scheme, a notification tap. Device registration writes the push token to a framework table, so the server side of notifications is a query rather than a integration project.
Native SDKs when React Native is not the answer
For a team that wants real Swift or Kotlin, the CLI emits a typed native SDK package from the same api surface. It is generated from your procedures, so it cannot drift from them the way a hand-written client does.
Expo owns the dev loop. We say so.
The one thing we deliberately do not take over
Metro is Expo's dev server and `voltro dev` does not run it. Your api runs under `voltro dev`; the app starts with `pnpm ios` and connects over the network. Pretending to own that loop would mean re-implementing a bundler we do not want to maintain, and the seam is honest: two dev servers, one api, one set of types.
The same three calls, on a phone.
// voltro.mobile.ts — which apis this app talks to
export default {
apis: { app: { package: '@acme/api' } },
}
// $ voltro codegen
// wrote .framework/mobileApis.generated.ts (apis app)The screen is the same code a web app runs. The boot order is the mobile-specific part and it is load-bearing: persisted state is hydrated before the first render, because a store is read DURING render and a render cannot await.
How a mobile app fits a Voltro project.
Do I need a separate backend for the mobile app?
No — and that is the whole shape of this. The api you already run serves your web app over a WebSocket, and the mobile app connects to the same one, calling the same procedures with the same authorisation rules. There is no mobile-specific endpoint layer, no second serialisation format, and no BFF to keep in step.
The binding between the two is generated. A small file names which apis the app talks to; the CLI turns it into the module the app imports, carrying the procedure surface and the cache metadata that drives optimistic updates. The types themselves ride the import of the api package, so a schema change reaches the phone without regenerating anything.
What that removes is the layer most teams write twice: a hand-maintained mobile client whose row types are copies of the server's. Copies drift, and the drift surfaces as a crash on a device rather than a red build.
What happens when the connection drops?
The supervisor re-dials with exponential backoff and swaps the new connection in when it opens. Screens do not blank while that happens: the new connection is seeded with the previous one's rows, so a tunnel or a lift degrades to slightly stale data rather than a page of skeletons.
That seeding stops at exactly one boundary, and the boundary is the reason this code is shared rather than copied. When the reconnect is triggered by a sign-in, a sign-out or a tenant switch, the caches are blanked instead — the next user may be entitled to strictly less than the previous one, and carrying rows across that swap would paint one person's data into another's screens.
Connection health is a UI surface, not a hidden state. A status of connected, degraded or offline comes from the device's real reachability signal plus the failures the app reports, so an offline banner appears because the phone is offline rather than because a request happened to fail.
How does offline actually behave?
The app opens. Persisted state is loaded from the device before the first screen renders — deliberately awaited, because a store is read during render and a render cannot wait for a disk. Rendering first and hydrating after is what produces the flash of empty state every hand-rolled version has.
Writes are optimistic by default, so a tap updates the screen immediately and reconciles when the server confirms. The posture is a value you can read and override rather than a set of hidden defaults: local-first, sync on foreground, a cadence, a retry schedule.
One deliberate limitation, stated rather than buried: a device has no synchronous storage, so persisted state is served from memory that was filled at startup. The consequence is the awaited hydration above, and the framework refuses to start a persistence layer it cannot enumerate — an empty cache is indistinguishable from a first run, which is the hardest kind of data loss to attribute.
What is not verified in our repository?
That the loop runs on a device. Every piece above is unit-tested without a simulator, and only a simulator can prove that the app boots under Metro and streams a live query. There is no iOS or Android toolchain in the framework repository, so booting the app is a step in your Expo or EAS pipeline.
We would rather say that than imply coverage we do not have. The distinction matters when you are choosing a stack: the transport, the reconnect rules, the persistence adapter and the code generation are all exercised by tests you can read, and the integration with a native runtime is exercised by you.
The two things that most often go wrong on a first run are documented for the same reason. `localhost` on a phone is the phone, so the connection address is derived from the machine running Metro rather than baked in. And the reachability signal has to come from the device's network module — the browser API the web uses does not exist in React Native and would report a phone in airplane mode as online.
What ships, and what a device has to prove.
| Capability | What it is |
|---|---|
| Typed hooks | The same `useSubscription` / `useMutation` as the web, bound to the api's generated procedure map. |
| Connection | Shared reconnect supervisor: backoff, generation tracking, and a cache-blanking rule across a subject change. |
| Persistence | Persisted stores over the device's storage, hydrated before first render, written through and coalesced. |
| Reachability | An injected source — the device's network module — driving a connected / degraded / offline status. |
| Deep links + push | One typed link table and matcher; device registration into a framework table for server-side fan-out. |
| Device boot | Your Expo or EAS CI step — there is no mobile toolchain in the framework repository. |
Frequently asked questions
Can I use my existing Voltro api with React Native?
Yes. Add a mobile app to the project and point it at the api by package name; the CLI generates the binding and the app connects over the same WebSocket your web client uses. No endpoint changes on the server.
Does `voltro dev` run the Expo app?
No, and that is deliberate. Expo owns Metro. The api runs under `voltro dev`; the app starts with its own command and connects over the network — two dev servers, one api, one set of types.
Does it work offline?
The app opens offline with persisted state loaded before the first screen, writes optimistically, and reconciles when the connection returns. Sync status is a first-class UI surface rather than something you infer from failed requests.
What about push notifications?
Device registration and the server-side table are built, so storing tokens and fanning out to them is framework code. The sending adapters for Apple and Google are not, because they need your own developer credentials — that part is genuinely external and we do not pretend otherwise. Web push is the exception and is first-party end to end: payloads encrypted per RFC 8291, VAPID auth per RFC 8292, one minted secret and a shipped service worker — though on iOS it reaches installed home-screen web apps only, never a browser tab.
What if we want native Swift or Kotlin instead?
The CLI emits a typed SDK package for either from the same api surface, generated from your procedures so it cannot drift from them. Compiling the emitted package is a step in your own mobile CI.
What a mobile app connects to.
Reactive queries
The subscription layer the mobile hooks read — one change feed, no invalidation rules to write.
Local-first
Collaborative editing and conflict-free merging, for the screens where two people type at once.
Templates
The Expo template this page describes, scaffolded next to your api by one CLI call.
Open the framework. See it for yourself.
Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.