Mobile app
apps/mobile is an Expo app that uses the same API and the same contracts as the web app.
Start it with bun run dev:mobile, then press i for the iOS simulator or a for Android:
it opens in Expo Go.
To put it on phones and in the stores, see Mobile builds.
What is in it
Section titled “What is in it”Four tabs at the bottom, drawn by the system: glass on iOS 26, Material on Android. Each tab
is a folder in apps/mobile/app/(app) with a stack of screens of its own.
| Screen | File in apps/mobile/app |
|---|---|
| Sign in, sign up, forgot password | (auth)/ |
| Home tab: your product goes here | (app)/(home)/index.tsx |
| Assistant tab: streamed Markdown answers and a History, by plan | (app)/assistant/index.tsx |
| Audit log tab, for admins on a plan that has it | (app)/audit-log/index.tsx |
| Account tab: workspace switcher, notifications, theme, sign out | (app)/account/index.tsx |
| Name, email and password | (app)/account/profile.tsx |
| People and invitations (teams) | (app)/account/members.tsx |
| Delete account | (app)/account/delete-account.tsx |
The app follows the phone’s light or dark mode. “Theme” on the account tab lets a person pick
one instead, and the choice is kept on the device (src/lib/appearance.ts). Colors come from
useTheme() in src/theme.ts: change the brand there, with the rest of the apps, as
Change the design shows.
Some things stay on the web on purpose:
- Buying or changing a plan. The app shows the plan and what it includes. The app stores
have rules about selling a subscription from inside an app, so
RequireFeaturenames the plan and links nowhere. - The links in emails. Verifying an address, resetting a password and approving a new email open the web app. After verifying, the person comes back to the app and signs in.
- Magic links, workspace settings and the admin panel.
Keep “Delete account” when you make the app yours: Apple requires it of any app that lets people create an account.
Signing in
Section titled “Signing in”The app signs in through the same Better Auth endpoints as the web app
(src/lib/auth-client.ts). A phone has no cookie jar, so the session cookie is kept in the
device’s secure storage and sent by hand, by the auth client and by api().
The API accepts sign-in from the app because it names its URL scheme, which must match
MOBILE_SCHEME. If you change scheme in apps/mobile/app.json, change MOBILE_SCHEME
to the same value, in .env and in production.
app/_layout.tsx loads /api/me and shows the screens in (app) to someone signed in and
the ones in (auth) to everyone else. There is nothing to guard in a screen of your own.
Add a screen
Section titled “Add a screen”-
Create
app/(app)/(home)/projects.tsxwith a default export, and give it a title inapp/(app)/(home)/_layout.tsx:<Stack.Screen name="projects" options={{ title: 'Projects' }} />. -
Read the workspace with
useWorkspace()and call the API withapi()and the contract schema, as the web app does:const workspace = useWorkspace();const { data } = useQuery({queryKey: ['workspaces', workspace.id, 'projects'],queryFn: () => api(`/workspaces/${workspace.id}/projects`, projectsSchema),}); -
Build it from
Screen,Section,Card,RowsandRow,TextFieldandButtoninsrc/components, and check a form withvalidate(schema, values)fromsrc/lib/form.ts. -
Link to it with
router.push('/projects').
Add a tab
Section titled “Add a tab”Add a folder beside the others, app/(app)/projects, with an index.tsx and a _layout.tsx
copied from app/(app)/assistant/_layout.tsx. Then add its trigger to
app/(app)/_layout.tsx:
<NativeTabs.Trigger name="projects"> <NativeTabs.Trigger.Label>Projects</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="folder" md="folder" /></NativeTabs.Trigger>sf is an SF Symbol for iOS and md a
Material symbol for Android. Keep to five tabs or fewer.
Expo still ships these tabs as expo-router/unstable-native-tabs: the import may change name
in a later SDK.
useWorkspace().can('admin') and .has('auditLog') decide what to show, as on the web. With
teams off the workspace is the person’s own, and the switcher on the account tab is hidden.
Links are checked against the screens that exist. The list is written while bun run dev:mobile runs, so start it once after adding a screen, or bun run check does not know
the new one.
Push notifications
Section titled “Push notifications”Send one from anywhere in the API:
await this.push.notify(user.id, { title: 'Your report is ready', body: 'Tap to read it.', data: { url: '/account' },});PushService is available in every module. The notification goes through the
job queue to Expo’s push service and on to every phone the person is
signed in on. Tapping it opens the screen named by url. Someone with no phone gets nothing,
and that is not an error.
For something the person should also find in the app later, send a notification instead: it is kept, shown in the Notifications tab, and sent to their phone when they want it.
On the phone, “Turn on notifications” on the account tab asks for permission and registers the device; “Send a test notification” shows that it works. Signing out stops notifications to that phone.
Push needs a real phone and a build of your own: a simulator and Expo Go cannot receive notifications. See Mobile builds.
In tests nothing is sent. t.pushes.notifications() returns what would have been:
expect((await t.pushes.notifications()).at(-1)).toMatchObject({ title: 'Your report is ready' });