Expo
For teams that need specific screens in React Native. Set engine: 'expo', list the routes you want to rebuild natively, and every other page keeps working exactly as it does in the Capacitor app.
#Why Expo
Capacitor renders every screen in a WebView, which is the right default. Expo earns its place when a screen needs what only native views can do:
If none of these apply to your app, stay with Capacitor.
#Trade-offs
| Feature | Capacitor | Expo |
|---|---|---|
| Native project Expo’s project is regenerated from your config on every sync; change absolute.config.ts, not the generated files. | mobile/, yours to edit | .absolutejs/mobile/expo, generated |
| Screens in React Native | Routes you list in routes.native | |
| Native route framework | — | React Native only |
| Every other page | Your page | Your page, in a WebView |
| Device features, Auth, Sync, push | ||
| Over-the-air updates |
#How it works
Web routes are not converted to React Native. A React, Svelte, Vue, Angular, HTML or HTMX page is packaged and rendered exactly as in the Capacitor app. Only the modules you list use React Native.
#Configure
Everything else in mobile works the same for both engines. routes.native maps a path pattern to a React Native module, relative to your project root. The generated app uses Expo SDK 57.
import { defineConfig } from '@absolutejs/absolute';
export default defineConfig({
mobile: {
engine: 'expo',
appId: 'com.example.shop',
appName: 'Shop',
server: { productionOrigin: 'https://shop.example.com' },
routes: {
native: {
'/scanner': './mobile/native/scanner.tsx',
'/products/:productId': './mobile/native/product.tsx',
'/files/*': './mobile/native/files.tsx'
}
}
}
});| Pattern | Rule |
|---|---|
| /scanner | A static path |
| /products/:productId | A named parameter matches one segment |
| /files/* | A final * matches one or more segments, as params.absoluteWildcard |
| Not allowed | Query strings, fragments, trailing slashes, a root /*, repeated parameter names, two patterns that match the same URLs |
| Reserved | /__absolute/native and first segments such as _expo, assets, public and manifest |
#Write a native route
A native route is a React Native component whose props come from your existing server route. You do not write an API for it: the handler that renders the page on the web returns the same props to the app.
// The same handler serves the web page and the native route's pageProps
.get('/products/:productId', async ({ params, request }) =>
handleReactPageRequest({
index: asset(manifest, 'ProductIndex'),
Page: Product,
props: await loadProduct(params.productId),
request
})
)// mobile/native/product.tsx
import { FlatList, Pressable, Text } from 'react-native';
import type { AbsoluteNativeRouteProps } from '@absolutejs/absolute/mobile';
import type { ProductPageProps } from '../../src/frontend/pages/Product';
export default function Product({
pageProps,
params,
reload
}: AbsoluteNativeRouteProps<ProductPageProps, { productId: string }>) {
return (
<FlatList
data={pageProps.variants}
keyExtractor={(variant) => variant.id}
ListHeaderComponent={<Text>{pageProps.name} #{params.productId}</Text>}
onRefresh={reload}
refreshing={false}
renderItem={({ item }) => (
<Pressable>
<Text>{item.label}</Text>
</Pressable>
)}
/>
);
}#Auth, Sync and devices
Your code does not change. Native routes and web routes both use @absolutejs/auth/client, @absolutejs/sync and @absolutejs/devices; the Expo app installs these implementations for you, as soon as your package.json lists the package.
@absolutejs/auth-expoSign-in through the system browser with PKCE, credentials in SecureStore. App code keeps using @absolutejs/auth.
@absolutejs/sync-expoEncrypted SQLite storage, reconnect on foreground, and background sync for unchanged @absolutejs/sync clients.
@absolutejs/devices-expoThe Expo implementation of every @absolutejs/devices feature, including push through APNs and FCM.
Push uses the device's APNs or FCM token directly and registers it with your server exactly as in the Capacitor app; see Push notifications.
#The bridge
Web routes call a fixed list of native methods: device features, HTTP, sign-in and Sync. Nothing else on the native side is reachable from a page.
| Limit | Value |
|---|---|
| Message size | 64 KiB |
| HTTP request and response bodies | 48 KiB |
| Files (photos, documents) | 64 MiB, transferred in 24 KiB chunks |
| Sync socket frames | 4 MiB |
| Requests | 30 seconds; pickers and permission prompts 5 minutes |
| HTTP | Production origin only; your own Authorization headers and redirects are refused |
Sync over the bridge uses the default JSON serializer.
#Development
bun dev generates the Expo project, offers to install the pinned SDK and Android toolchain, and starts Metro, the development client and your server together. It rebuilds the native app when your config, package.json or lockfile changes.
bun dev # Metro, the dev client and your server together
bun dev --android-device emulator-5554
bun dev --ios-device "iPhone 16"iOS needs macOS, or a Mac paired with absolute mobile pair mac. Development covers devices, HTTPS and the remote Mac.
#Release and updates
Store builds, the release doctor, certification and publishing use the same commands as Capacitor. Each build regenerates the native project from scratch, so nothing left over from development reaches the store.
bunx absolute mobile build android src/backend/server.ts
bunx absolute mobile build ios src/backend/server.ts
bunx absolute mobile publish android --play-track internalOver-the-air updates use the standard expo-updates client against your own AbsoluteJS server, with an RSA code-signing certificate built into the app. Over-the-air updates covers keys and rollout.