The coach dashboard shows a trainee’s app inside a phone frame. That frame is not a mock. It is this same Expo app, exported for the web and loaded in an iframe. Because it is the real code, it cannot drift from what the trainee sees. The preview is read-only. A coach can look, and cannot save anything as the trainee.

Pieces

Exporting

scripts/export-preview.sh:
  1. Resolves the base path from PREVIEW_BASE_PATH, default /trainee-preview.
  2. Resolves the target folder from PREVIEW_TARGET_DIR, default ../frontend/apps/saas/public/trainee-preview.
  3. Runs npx expo export -p web --clear into a temporary folder, passing PREVIEW_BASE_PATH and PREVIEW_API_URL.
  4. Fails if the export has no index.html.
  5. Deletes the old target folder and moves the new export in.
Staging the export in a temp folder first means a failed export never leaves the dashboard with a half-written bundle. The exported files are committed in the frontend repository, under apps/saas/public/trainee-preview. After changing the mobile app, re-run the export and commit the result there, or the dashboard keeps showing the old app.

app.config.js

app.json is static. app.config.js wraps it and only changes anything when one of the two preview variables is set:
  • experiments.baseUrl makes Expo Router and asset URLs work under a sub-path.
  • extra.apiUrl is the first thing src/lib/api/config.ts checks when resolving the API base URL.
Native builds never set these variables, so they get app.json unchanged.

Single-page output

app.json sets web.output to single. The export is a client-rendered single-page app. Static rendering would execute native-only modules in Node at build time.

Making the app bundle for web

Shims

metro.config.js replaces eight native-only packages with stand-ins when platform === 'web': expo-glass-effect, expo-widgets, @expo/ui/swift-ui, @expo/ui/swift-ui/modifiers, react-native-webview, expo-camera, expo-media-library and react-native-view-shot. The swap is at the resolver level. TypeScript still checks against the real packages, and no feature file needs a .web.tsx twin. What each shim does is listed in Guarding native imports. The react-native-webview shim renders a real iframe, so content PDFs and embedded videos still display in the preview.

Tabs

expo-router/unstable-native-tabs has no web implementation. _layout.web.tsx uses the JS Tabs navigator with Ionicons, the same five tabs and the studio tint. This is the one deliberate visual difference from the phone.

Storage

secureStore.ts, jsonFileStore.ts and language.ts all fall back to localStorage on web.

Direction

react-native-web does not implement I18nManager. applyAppDirection() sets dir and lang on the document element instead. See RTL and language.

Preview mode

src/lib/preview.ts decides once, at module load:
Preview mode means “web, and inside a frame”. Opening the same export directly in a browser tab is not preview mode. It behaves like a normal web build with the regular login.

Token handshake

The app never shows a login in preview mode. It asks the page that embeds it for a token. Constants in src/constants/preview.ts: The host side declares the same two message names in TRAINEE_PREVIEW_MESSAGE. They must match. requestPreviewToken() posts ready to window.parent at once and then every 250 ms, because the app can finish loading before the host has attached its listener. It resolves with the token from the first valid token message, or with null after 8 seconds. authStore.hydrate() takes a separate path in preview mode:
  • It does not read SecureStore.
  • It sets the in-memory token and marks onboarding as finished.
  • With a token, status becomes authed and refreshSession() loads the trainee and branding.
  • With no token, status becomes guest.
  • The token is never written to storage.
restoreSession() does nothing in preview mode.

Read-only, enforced twice

In the app. apiClient rejects every non-GET request before sending it:
On the server. The trainee auth middleware rejects non-GET requests from a preview principal with a FORBIDDEN error. The app-side check is a courtesy for fast feedback. The server check is the real guard. Device-local writes are skipped too. These modules return early when IS_PREVIEW is true: Without these, a coach previewing two trainees in the same browser would see one trainee’s local state in the other’s preview, since web storage is shared per origin.

Hosting

The Next.js app serves the export as static files from public/trainee-preview. next.config.ts adds a beforeFiles rewrite from /trainee-preview to /trainee-preview/index.html. Without it the bare path would fall through to the dashboard’s organization route. Expo Router needs the base path itself, not /index.html, to resolve its routes. The host reads the preview URL from NEXT_PUBLIC_TRAINEE_PREVIEW_URL, default /trainee-preview. The frame is sized from TRAINEE_PREVIEW_VIEWPORT, 390 by 844.

What does not work on web

  • Home screen widgets and the Live Activity (inert shims).
  • Push notifications, HealthKit and Health Connect. getHealthProvider() returns null and the update prompt returns early.
  • Camera capture and saving to the photo library.
  • Story image capture.
  • Anything that writes, by design.

Running the web build locally

This starts the dev server for the web target. Opened directly, it is a normal web build and shows the login. To exercise preview mode you need the dashboard as the parent frame, with PREVIEW_API_URL or EXPO_PUBLIC_API_URL pointing at a backend that will issue preview tokens.

Adding a native dependency

A new package with no web implementation breaks the export, not only old binaries. After adding one:
  1. Run pnpm preview:export and confirm it finishes.
  2. If it fails on the new package, add a shim in src/lib/web-shims and an entry in WEB_SHIMS, or guard the import.
  3. Load the preview in the dashboard and open the screens that use the feature.