Skip to content

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.

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 RequireFeature names 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.

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.

  1. Create app/(app)/(home)/projects.tsx with a default export, and give it a title in app/(app)/(home)/_layout.tsx: <Stack.Screen name="projects" options={{ title: 'Projects' }} />.

  2. Read the workspace with useWorkspace() and call the API with api() 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),
    });
  3. Build it from Screen, Section, Card, Rows and Row, TextField and Button in src/components, and check a form with validate(schema, values) from src/lib/form.ts.

  4. Link to it with router.push('/projects').

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.

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' });