Expo prototype for a native Fluid portal shell — one builder publish, rendered as a real native app.
📊 Proposal deck: native-shell-site.vercel.app — the "why" and the roadmap, in 12 slides.
Status: proof of concept. It runs against the live portal-tenant BFF, but the native-feel layer is built with injected-CSS/JS heuristics that a
native_shell=1render mode in the portal SDK would eventually replace (see the deck and the contract section below).
The app demonstrates the recommended hybrid model:
- the native Expo app owns top-level chrome, tab navigation, auth/account, messaging, notifications, profile, badges, deep links, and push permissions
- builder-published screens render as body-only portal canvases through
react-native-webview - admin nav changes flow through a manifest, so app layout can change without shipping a new binary
Navigation is a root native stack with three route types:
tabs— bottom tab navigator built frommanifest.bottomTabs. Tabs are pre-mounted (lazy: false) so switching is instant, and the tab bar is a translucent blur bar (iOS) that canvas content scrolls underneath (a matching bottom inset is injected into each page).portal-link— any builder link that isn't a root tab presents as a native page sheet (system card transition, swipe down to dismiss) with a real navigation bar: in-page back, live title sync from the page, native share sheet.more-route— routes listed under More push as real native screens with swipe-back.
Other native-feel details:
- themed skeleton placeholders while canvases load — the WebView never flashes white
- pull-to-refresh inside every canvas
- haptic feedback on tab presses, link presentation, and publish
- offline/error state with retry, styled with the portal theme
- native
Sharesheet forsharebridge messages
The shell injects window.FluidNativeApp = { version, capabilities } and listens for
window.ReactNativeWebView.postMessage(JSON.stringify(message)) with these message types:
| type | payload | behavior |
|---|---|---|
navigate |
{ slug } |
jump to a native tab / manifest route |
presentPortalLink |
{ url, title? } |
present a builder page as a native sheet |
openExternalUrl |
{ url } |
open in the system browser |
share |
{ title, url } |
native share sheet |
setTitle |
{ title } |
update the native header title (sheets) |
haptic |
{ style } |
selection | light | medium | success | warning |
Pinned to Expo SDK 54 (React Native 0.81) specifically so it runs in the App Store build of Expo Go — no dev build or custom binary required.
On your phone (recommended — this is where the haptics and native sheets are the point):
- Install Expo Go from the App Store / Play Store.
- Clone and start the bundler:
npm install npx expo start
- Scan the QR code with Expo Go (make sure your phone is on the same Wi-Fi as your machine). If the LAN QR won't connect, run
npx expo start --tunnel.
On the iOS Simulator (needs Xcode): press i in the Expo CLI, or npm run ios.
The portal canvases load p.portal.fluid.app, which needs a session. On a fresh launch with no session you'll get a native login screen hosting the portal's own sign-in; once you're in, the shared cookie makes the manifest fetch succeed and the live tabs + theme swap in. The Sync latest publish row under More force-refreshes nav and theme from the builder — use it to see an admin publish reorder the app without a rebuild.
The shell loads its navigation from the real portal-tenant BFF endpoint the web portal renders from:
GET {portal}/api/app/manifestsrc/data/live-manifest.ts fetches it using the WebView's shared session cookie and projects
manifest.profile.navigation.navigation_items into native tabs using the web shell's exact rules
(position sort, top-level filter, first 5 tabs + More overflow, first navigable slug = home/launch tab).
The manifest re-syncs on app foreground and via the header sync button, so a builder publish reorders
the app without shipping a binary. src/data/mock-native-manifests.ts remains only as offline/no-session
sample data, and first render is gated behind a branded splash so sample nav never flashes.
Builder-authored routes should point to a portal runtime mode like:
/home?native_shell=1&chrome=noneIn that mode, the portal SDK should render the screen body only (today the shell approximates this with injected CSS). Expo owns the native shell around it.