Mobile apps
Build an iOS and Android client for a DeepSpace app with Expo and deepspace/native.
On this page
A DeepSpace app can have a native mobile client built with Expo (React Native). The client talks to the same Worker as the website, so it reads the same records under the same permissions, calls the same integrations and server actions, and signs in to the same accounts. Mobile support requires deepspace 0.39 or later and Expo SDK 57; use 0.39.2 or later so that signing out also ends the session on the server.
Add a mobile client#
From the app directory:
npx deepspace add mobile
This writes an Expo project to mobile/. Its package.json pins the same deepspace version as the app, and mobile/app.config.ts reads the app id and app.space name from wrangler.toml, so the client always points at the app it belongs to. Nothing in the website changes; the linter is told to skip mobile/.
The client signs in through a custom URL scheme: the app name with the dashes removed (my-app becomes myapp). Allowlist its callback in wrangler.toml and deploy the Worker:
[vars]
NATIVE_AUTH_REDIRECT_URIS = "myapp://auth/callback"
Until the callback is listed, mobile/app.config.ts stops the build and prints the exact line to add. List several callbacks separated by commas; the Worker accepts only exact matches.
Then run the app in the iOS Simulator or an Android emulator:
cd mobile
npm install
npx expo run:ios # or: npx expo run:android
Sign-in#
Create one client at module scope and render one DeepSpaceNativeProvider at the root. useDeepSpace returns the auth state with signIn and signOut:
import { createDeepSpaceExpoClient, DeepSpaceNativeProvider, useDeepSpace } from 'deepspace/native'
const deepSpace = createDeepSpaceExpoClient({ baseUrl: 'https://my-app.app.space' })
export default function App() {
return (
<DeepSpaceNativeProvider client={deepSpace}>
<Root />
</DeepSpaceNativeProvider>
)
}
function Root() {
const { isLoaded, isSignedIn, signIn } = useDeepSpace()
if (!isLoaded) return null
if (!isSignedIn) return <SignInButton onPress={() => signIn('google')} />
return <Home />
}
signIn opens the provider's sign-in page in the system browser. The Worker returns a one-time code to the app's callback, and the app redeems it with a PKCE verifier that it sends straight to the Worker, never through the browser or the callback URL, so another app that intercepts the callback cannot use the code. The long-lived session is stored in the device keychain (Expo SecureStore); requests carry a short-lived token that the client refreshes shortly before it expires. A network failure during refresh keeps the session; only a definitive rejection signs the person out. signOut revokes the session on the server and clears it from the device.
If the person closes the sign-in sheet, signIn throws an error whose code is sign_in_cancelled. Treat it as a cancellation, not a failure, and check code rather than instanceof:
try {
await signIn('google')
} catch (error) {
if ((error as { code?: string }).code !== 'sign_in_cancelled') throw error
}
Records#
Records work as they do on the web. Wrap signed-in screens in RecordProvider and RecordScope, then use useQuery and useMutations. Import the schemas from the app's src/ folder so the client and the Worker share one definition:
import { RecordProvider, RecordScope, useMutations, useQuery } from 'deepspace/native'
import { schemas } from '../src/schemas'
function Home() {
return (
<RecordProvider>
<RecordScope roomId={`app:${APP_ID}`} schemas={schemas}>
<Items />
</RecordScope>
</RecordProvider>
)
}
RecordScope connects to the client's baseUrl and reconnects when the app returns to the foreground. Phones lose connectivity more often than browsers: when a write must not be lost, use the confirmed mutations (createConfirmed, putConfirmed, removeConfirmed), which reject if the connection drops before the Worker accepts the write. See data storage for queries, permissions, and write errors.
The user, presence, and messaging hooks are available too: useUser, useUsers, useUserLookup, usePresence, useMessages, useChannels, useReactions, useChannelMembers, and useReadReceipts.
Integrations and server actions#
integration calls the app's integration proxy exactly as on the web, and callAction posts to a server action:
import { callAction, integration } from 'deepspace/native'
const places = await integration.post('openweathermap/geocoding', { query: 'Brooklyn' })
const result = await callAction('archiveProject', { projectId })
if (!result.success) showError(result.error)
Per-user integrations such as Google answer with a consent URL until the person grants access (see Google OAuth). consentUrlOf reads that URL from a result, and openIntegrationConsent shows it in the system browser and resolves when the browser closes. The platform stores the grant, so retry the call afterwards:
import { consentUrlOf, integration, openIntegrationConsent } from 'deepspace/native'
let result = await integration.post('google/calendar-list-events', { maxResults: 10 })
const consentUrl = consentUrlOf(result)
if (consentUrl) {
await openIntegrationConsent(consentUrl)
result = await integration.post('google/calendar-list-events', { maxResults: 10 })
}
What the mobile entry includes#
deepspace/native exports the Expo client (createDeepSpaceExpoClient and its errors), DeepSpaceNativeProvider, useDeepSpace, useAuth, the record, user, presence, and messaging hooks above, integration, callAction, consentUrlOf, and openIntegrationConsent. Import everything from deepspace/native so the app holds one copy of the client.
The web-only surfaces are not part of it: AuthOverlay, AuthGate, theming, file uploads with useR2Files, and the canvas, cron, job, and collaborative-editing hooks.
Build for devices#
Device and store builds go through EAS Build and need an iOS bundle identifier and Android package registered to your developer accounts. npx deepspace add mobile fills in placeholders based on the app name; replace them in mobile/app.config.ts before the first device build. No native project is committed: expo run:ios, expo run:android, and EAS generate it.