Pieces
Exporting
scripts/export-preview.sh:
- Resolves the base path from
PREVIEW_BASE_PATH, default/trainee-preview. - Resolves the target folder from
PREVIEW_TARGET_DIR, default../frontend/apps/saas/public/trainee-preview. - Runs
npx expo export -p web --clearinto a temporary folder, passingPREVIEW_BASE_PATHandPREVIEW_API_URL. - Fails if the export has no
index.html. - Deletes the old target folder and moves the new export in.
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.baseUrlmakes Expo Router and asset URLs work under a sub-path.extra.apiUrlis the first thingsrc/lib/api/config.tschecks when resolving the API base URL.
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:
Token handshake
The app never shows a login in preview mode. It asks the page that embeds it for a token. Constants insrc/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,
statusbecomesauthedandrefreshSession()loads the trainee and branding. - With no token,
statusbecomesguest. - 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:
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 frompublic/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
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:- Run
pnpm preview:exportand confirm it finishes. - If it fails on the new package, add a shim in
src/lib/web-shimsand an entry inWEB_SHIMS, or guard the import. - Load the preview in the dashboard and open the screens that use the feature.