mobile-app
Hybrid Expo + Next.js application for web, iOS, and Android. AI chat with streaming, Better Auth with biometric support, NativeWind Tailwind styling. Single codebase for all platforms.
Quick Start
Launch on web and mobile in 1 minute:
pnpm --filter mobile-app dev # Web at localhost:3000
pnpm --filter mobile-app dev:native # Expo dev serverOne codebase → web, iOS, Android. Skip to Quick Start →
Why mobile-app?
Separate web and mobile codebases. Duplicated authentication logic. Inconsistent styling across platforms. AI features implemented twice. Deep linking requires platform-specific code. Build processes differ for each target. No shared component library.
mobile-app solves this by using Expo and Next.js together—one codebase that compiles to web (Next.js), iOS (Expo), and Android (Expo) with shared components and authentication.
Production-ready with Expo 54, Next.js 16, Vercel AI SDK v6, Better Auth with biometrics, NativeWind v4, Expo Router, deep linking, and EAS Build integration.
Use cases
- Cross-Platform AI Chat — Streaming chat on web, iOS, and Android
- Biometric Authentication — Face ID, Touch ID, fingerprint unlock
- Deep Linking — Open specific app screens from URLs or notifications
- Unified Design System — Tailwind CSS utilities work on web and native
- Progressive Web App — Web app with native-like experience
- Hybrid WebView — Embed oneapp-onstage web features in native shell
How it works
mobile-app uses Expo Router for native navigation and Next.js for web:
// components/ChatScreen.tsx - Works on web, iOS, Android
import { useChat } from "ai/react";
import { View, TextInput, FlatList, Text } from "react-native";
export function ChatScreen() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat",
});
return (
<View className="flex-1 bg-white"> {/* NativeWind Tailwind classes */}
<FlatList
data={messages}
renderItem={({ item }) => (
<View className={item.role === "user" ? "bg-blue-100" : "bg-gray-100"}>
<Text className="text-base p-3">{item.content}</Text>
</View>
)}
/>
<TextInput
value={input}
onChangeText={(text) => handleInputChange({ target: { value: text } })}
onSubmitEditing={handleSubmit}
placeholder="Type a message..."
className="border-t border-gray-300 p-3"
/>
</View>
);
}Uses Expo for native builds, Next.js for web builds, Better Auth for authentication, and NativeWind for unified Tailwind styling.
Key features
Cross-Platform — Web, iOS, Android from single codebase
AI Chat — Streaming with Vercel AI SDK v6
Better Auth — Biometric authentication (Face ID, Touch ID, fingerprint)
NativeWind — Tailwind CSS utilities for React Native
Deep Linking — Custom URL scheme (mobileapp://)
Expo Router — File-based routing for native apps
Hybrid WebView — Embed oneapp-onstage web features with native bridge
Hybrid WebView Integration
For complex features like rich text editors, the app uses a hybrid approach: native shell for navigation and auth, WebView for web-based features.
┌─────────────────────────────────────┐
│ Native Shell (Expo Router) │
│ ├── Auth (native, Better Auth) │
│ ├── Push Notifications (native) │
│ ├── Tab Bar (native) │
│ └── Screens: │
│ ├── Home → Native │
│ ├── Chat → Native │
│ ├── OneApp → WebView │ ← Web features
│ └── Settings → Native │
└─────────────────────────────────────┘
↕ NativeBridge API ↕
┌─────────────────────────────────────┐
│ oneapp-onstage (Next.js WebView) │
│ - Full web experience │
│ - Calls native features via bridge │
└─────────────────────────────────────┘Using OneAppWebView
import { OneAppWebView } from "#/lib/webview";
export default function OneAppTab() {
return (
<OneAppWebView
path="/" // Path on oneapp-onstage
injectAuth={true} // Pass auth token to web
onNavigate={(route) => { // Handle web → native navigation
router.push(route);
}}
/>
);
}Native Bridge Features
The WebView includes a native bridge that lets the web app call native features:
| Feature | Method | Description |
|---|---|---|
| Haptics | NativeBridge.haptic('success') | Trigger haptic feedback |
| Clipboard | NativeBridge.copyToClipboard(text) | Copy to clipboard |
| Share | NativeBridge.share({ message, url }) | Open native share sheet |
| Auth | NativeBridge.getAuthToken() | Get auth token from secure storage |
| Navigation | NativeBridge.navigateNative('/profile') | Navigate to native screen |
See @repo/uni-app/native-bridge for the web-side API.
Quick Start
1. Start the web app
pnpm --filter mobile-app dev2. Start the native app
# Start Expo dev server
pnpm --filter mobile-app dev:native
# Then press 'i' for iOS or 'a' for Android3. Create a cross-platform screen
import { View, Text, Pressable } from "react-native";
import { useSession, signOut } from "#/lib/auth-client";
export default function ProfileScreen() {
const { data: session } = useSession();
return (
<View className="flex-1 p-4 bg-white">
<Text className="text-2xl font-bold mb-4">
Hello, {session?.user?.name}
</Text>
<Pressable
onPress={() => signOut()}
className="bg-red-600 rounded-lg p-4"
>
<Text className="text-white text-center font-semibold">
Sign Out
</Text>
</Pressable>
</View>
);
}4. Build for production
# Web build (Next.js)
pnpm --filter mobile-app build:web
# Native builds (EAS)
npx eas build --platform ios
npx eas build --platform androidThat's it! Your app runs on web browsers via Next.js and on iOS/Android devices via Expo with shared components, authentication, and styling.
Development Tip
Use pnpm --filter mobile-app dev:tunnel to test on physical devices without being on the same network.
Technical Details
For Developers: Technical implementation details
Overview
| Property | Value |
|---|---|
| Location | platform/apps/mobile-app |
| Port | 3000 (web), dynamic (Expo) |
| Framework | Expo 54 + Next.js 16 |
| Routing | Expo Router (native), Next.js Pages (web) |
| Styling | NativeWind v4 (Tailwind CSS for RN) |
| Auth | Better Auth with Expo plugin |
| AI | Vercel AI SDK v6 |
| Tech Stack | React 19, TypeScript 5, React Native 0.78 |
Project Structure
mobile-app/
├── app/ # Expo Router (native)
│ ├── (tabs)/ # Tab navigation group
│ │ ├── _layout.tsx # Tab layout with protected routes
│ │ ├── index.tsx # Home tab ("/" on native)
│ │ ├── chat.tsx # Chat tab ("/chat" on native)
│ │ └── profile.tsx # Profile tab ("/profile" on native)
│ ├── (auth)/ # Auth routes group (no auth required)
│ │ ├── login.tsx # Login screen ("/login" on native)
│ │ └── signup.tsx # Signup screen ("/signup" on native)
│ ├── api/ # Native API routes (Expo API Routes)
│ │ └── chat+api.ts # Chat endpoint for native
│ ├── _layout.tsx # Root layout (session provider)
│ └── +not-found.tsx # 404 screen
├── pages/ # Next.js (web)
│ ├── index.tsx # Home page ("/" on web)
│ ├── chat.tsx # Chat page ("/chat" on web)
│ ├── profile.tsx # Profile page ("/profile" on web)
│ ├── login.tsx # Login page ("/login" on web)
│ ├── signup.tsx # Signup page ("/signup" on web)
│ ├── _app.tsx # Next.js app wrapper
│ ├── _document.tsx # Next.js document (HTML wrapper)
│ └── api/ # Web API routes
│ ├── chat.ts # Chat endpoint for web
│ └── [...auth].ts # Better Auth catch-all
├── components/ # Shared components (web + native)
│ ├── ChatScreen.tsx # Chat UI (works on both)
│ ├── LoginForm.tsx # Login form (works on both)
│ ├── Button.tsx # Universal button
│ └── ... # Other shared components
├── lib/ # Shared libraries
│ ├── auth.ts # Better Auth server config
│ ├── auth-client.ts # Better Auth client config
│ ├── ai-client.ts # AI SDK client utilities
│ └── utils.ts # Shared utilities
├── metro.config.js # Metro bundler configuration
├── next.config.mjs # Next.js configuration
├── app.config.ts # Expo configuration
├── eas.json # EAS Build configuration
├── tailwind.config.ts # Tailwind/NativeWind config
├── polyfills.ts # React Native polyfills
├── .env.local # Local environment variables
└── package.json # Dependencies and scriptsPlatform Routing
Native Routes (Expo Router)
Expo Router provides file-based routing similar to Next.js:
app/
├── (tabs)/ # Tab layout group
│ ├── _layout.tsx # <Tabs> navigator with protected auth
│ ├── index.tsx # Route: "/"
│ ├── chat.tsx # Route: "/chat"
│ └── profile.tsx # Route: "/profile"
├── (auth)/ # Auth group (no auth required)
│ ├── login.tsx # Route: "/login"
│ └── signup.tsx # Route: "/signup"
└── _layout.tsx # Root layout (SessionProvider, polyfills)Route Protection:
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { useSession } from "#/lib/auth-client";
import { Redirect } from "expo-router";
export default function TabsLayout() {
const { data: session, isPending } = useSession();
if (isPending) return <LoadingScreen />;
if (!session) return <Redirect href="/login" />;
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="chat" options={{ title: "Chat" }} />
<Tabs.Screen name="profile" options={{ title: "Profile" }} />
</Tabs>
);
}Web Routes (Next.js Pages)
pages/
├── index.tsx # Route: "/"
├── chat.tsx # Route: "/chat"
├── profile.tsx # Route: "/profile"
├── login.tsx # Route: "/login"
├── signup.tsx # Route: "/signup"
├── _app.tsx # App wrapper
├── _document.tsx # HTML document
└── api/
├── chat.ts # API route: "/api/chat"
└── [...auth].ts # API route: "/api/auth/*"Route Protection (HOC pattern):
// lib/with-auth.tsx
import { useSession } from "#/lib/auth-client";
import { useRouter } from "next/router";
import { useEffect } from "react";
export function withAuth<P extends object>(
Component: React.ComponentType<P>
) {
return function ProtectedRoute(props: P) {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.push("/login");
}
}, [session, isPending, router]);
if (isPending) return <LoadingScreen />;
if (!session) return null;
return <Component {...props} />;
};
}AI Chat Integration
Chat Screen Component
// components/ChatScreen.tsx
import { useChat } from "ai/react";
import {
View,
TextInput,
FlatList,
Text,
Pressable,
ActivityIndicator,
} from "react-native";
export function ChatScreen() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error,
} = useChat({
api: "/api/chat",
onError: (error) => {
console.error("Chat error:", error);
},
});
return (
<View className="flex-1 bg-white">
{/* Messages list */}
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View
className={`p-3 m-2 rounded-lg ${
item.role === "user"
? "bg-blue-100 self-end"
: "bg-gray-100 self-start"
}`}
>
<Text className="text-base">{item.content}</Text>
</View>
)}
ListFooterComponent={() =>
isLoading ? (
<ActivityIndicator size="large" className="my-4" />
) : null
}
/>
{/* Error display */}
{error && (
<View className="bg-red-100 p-3 m-2 rounded">
<Text className="text-red-800">{error.message}</Text>
</View>
)}
{/* Input field */}
<View className="flex-row items-center border-t border-gray-300 p-3">
<TextInput
value={input}
onChangeText={(text) =>
handleInputChange({
target: { value: text },
} as React.ChangeEvent<HTMLInputElement>)
}
placeholder="Type a message..."
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 mr-2"
editable={!isLoading}
/>
<Pressable
onPress={() => handleSubmit()}
disabled={!input.trim() || isLoading}
className={`px-4 py-2 rounded-lg ${
!input.trim() || isLoading
? "bg-gray-400"
: "bg-blue-600"
}`}
>
<Text className="text-white font-semibold">Send</Text>
</Pressable>
</View>
</View>
);
}Chat API Route (Native - Expo)
// app/api/chat+api.ts
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: anthropic("claude-3-5-sonnet-20241022"),
messages,
system: "You are a helpful AI assistant.",
maxTokens: 2000,
tools: {
getWeather: {
description: "Get the current weather for a location",
parameters: z.object({
location: z.string().describe("The city name")
}),
execute: async ({ location }) => {
// Mock weather data
return {
location,
temperature: 72,
conditions: "Sunny"
};
}
}
}
});
return result.toDataStreamResponse();
}Chat API Route (Web - Next.js)
// pages/api/chat.ts
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import type { NextRequest } from "next/server";
export const config = {
runtime: "edge" // Use Edge Runtime for streaming
};
export default async function handler(request: NextRequest) {
const { messages } = await request.json();
const result = streamText({
model: anthropic("claude-3-5-sonnet-20241022"),
messages,
system: "You are a helpful AI assistant.",
maxTokens: 2000
});
return result.toDataStreamResponse();
}Authentication
Server Configuration
// lib/auth.ts
import { betterAuth } from "better-auth";
import { expo } from "@better-auth/expo";
import Database from "better-sqlite3";
export const auth = betterAuth({
database: new Database("local.db"),
plugins: [
expo({
// Expo-specific settings
scheme: "mobileapp",
enableBiometrics: true
})
],
emailAndPassword: {
enabled: true,
requireEmailVerification: false // Set to true in production
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
},
apple: {
clientId: process.env.APPLE_CLIENT_ID!,
teamId: process.env.APPLE_TEAM_ID!,
keyId: process.env.APPLE_KEY_ID!,
privateKey: process.env.APPLE_PRIVATE_KEY!
}
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24 // 1 day
}
});
export type Session = typeof auth.$Infer.Session;Client Configuration
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import * as LocalAuthentication from "expo-local-authentication";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_API_BASE_URL || "http://localhost:3000",
plugins: [
expoClient({
scheme: "mobileapp",
storagePrefix: "mobile-app-auth",
storage: SecureStore,
biometrics: {
enabled: true,
authenticate: async () => {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to sign in",
fallbackLabel: "Enter password"
});
return result.success;
}
}
})
]
});
export const { signIn, signUp, signOut, useSession, resetPassword, changePassword } = authClient;Login Component (Cross-Platform)
// components/LoginForm.tsx
import { useState } from "react";
import { View, TextInput, Pressable, Text, Alert } from "react-native";
import { signIn } from "#/lib/auth-client";
import { useRouter } from "expo-router"; // Works on web with next/router alias
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
setIsLoading(true);
try {
await signIn.email({
email,
password,
});
router.push("/");
} catch (error) {
Alert.alert("Login Failed", error.message);
} finally {
setIsLoading(false);
}
};
return (
<View className="p-4 bg-white">
<TextInput
value={email}
onChangeText={setEmail}
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
className="border border-gray-300 rounded-lg px-3 py-2 mb-3"
/>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
className="border border-gray-300 rounded-lg px-3 py-2 mb-3"
/>
<Pressable
onPress={handleLogin}
disabled={isLoading}
className={`rounded-lg py-3 ${
isLoading ? "bg-gray-400" : "bg-blue-600"
}`}
>
<Text className="text-white text-center font-semibold">
{isLoading ? "Signing in..." : "Sign In"}
</Text>
</Pressable>
</View>
);
}Biometric Authentication
// lib/biometric-auth.ts
import * as LocalAuthentication from "expo-local-authentication";
export async function authenticateWithBiometrics() {
// Check if biometric hardware is available
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) {
throw new Error("Biometric hardware not available");
}
// Check if biometrics are enrolled
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!isEnrolled) {
throw new Error("No biometric credentials enrolled");
}
// Authenticate
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to access your account",
fallbackLabel: "Enter password instead",
disableDeviceFallback: false
});
return result.success;
}NativeWind Styling
Configuration
// tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a"
}
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["Fira Code", "monospace"]
}
}
}
} satisfies Config;Metro Configuration (React Native Bundler)
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
// Add NativeWind CSS support
module.exports = withNativeWind(config, {
input: "./global.css" // Path to Tailwind CSS file
});Global CSS File
/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;Usage Examples
// Universal components with Tailwind classes
import { View, Text, Pressable } from "react-native";
export function Card({ title, description, onPress }) {
return (
<Pressable
onPress={onPress}
className="bg-white rounded-lg shadow-md p-4 m-2 active:scale-95 transition-transform"
>
<Text className="text-xl font-bold text-gray-900 mb-2">{title}</Text>
<Text className="text-gray-600">{description}</Text>
</Pressable>
);
}
export function Button({ label, variant = "primary", onPress }) {
const variantClasses = {
primary: "bg-blue-600 active:bg-blue-700",
secondary: "bg-gray-200 active:bg-gray-300",
danger: "bg-red-600 active:bg-red-700",
};
return (
<Pressable
onPress={onPress}
className={`rounded-lg py-3 px-6 ${variantClasses[variant]}`}
>
<Text
className={`text-center font-semibold ${
variant === "primary" || variant === "danger"
? "text-white"
: "text-gray-900"
}`}
>
{label}
</Text>
</Pressable>
);
}Deep Linking
Expo Configuration
// app.config.ts
import { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "Mobile App",
slug: "mobile-app",
scheme: "mobileapp", // mobileapp://path
version: "1.0.0",
orientation: "portrait",
icon: "./assets/icon.png",
splash: {
image: "./assets/splash.png",
resizeMode: "contain",
backgroundColor: "#ffffff"
},
ios: {
bundleIdentifier: "com.yourcompany.mobileapp",
supportsTablet: true,
associatedDomains: ["applinks:yourdomain.com"]
},
android: {
package: "com.yourcompany.mobileapp",
adaptiveIcon: {
foregroundImage: "./assets/adaptive-icon.png",
backgroundColor: "#ffffff"
},
intentFilters: [
{
action: "VIEW",
data: [
{
scheme: "https",
host: "yourdomain.com"
}
],
category: ["BROWSABLE", "DEFAULT"]
}
]
},
plugins: ["expo-router", "expo-secure-store", "expo-local-authentication"]
};
export default config;Handling Deep Links
// app/_layout.tsx
import { useEffect } from "react";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
export default function RootLayout() {
const router = useRouter();
useEffect(() => {
// Handle initial URL (app opened from link)
Linking.getInitialURL().then((url) => {
if (url) {
handleDeepLink(url);
}
});
// Handle URL while app is running
const subscription = Linking.addEventListener("url", ({ url }) => {
handleDeepLink(url);
});
return () => subscription.remove();
}, []);
const handleDeepLink = (url: string) => {
const { path, queryParams } = Linking.parse(url);
// Examples:
// mobileapp://chat?id=123 → /chat with params
// https://yourdomain.com/chat → /chat
if (path) {
router.push({
pathname: `/${path}`,
params: queryParams,
});
}
};
return <Slot />;
}Creating Deep Links
// lib/deep-links.ts
import * as Linking from "expo-linking";
export function createDeepLink(path: string, params?: Record<string, string>) {
return Linking.createURL(path, {
queryParams: params
});
}
// Usage
const chatLink = createDeepLink("chat", { id: "123" });
// Returns: mobileapp://chat?id=123React Native Polyfills
Required Polyfills for AI SDK
// polyfills.ts
import "react-native-polyfill-globals/auto";
import { polyfillGlobal } from "react-native/Libraries/Utilities/PolyfillFunctions";
// Text encoding (required for AI SDK streaming)
import { TextEncoder, TextDecoder } from "text-encoding";
polyfillGlobal("TextEncoder", () => TextEncoder);
polyfillGlobal("TextDecoder", () => TextDecoder);
// ReadableStream (required for streaming responses)
import { ReadableStream } from "web-streams-polyfill";
polyfillGlobal("ReadableStream", () => ReadableStream);
// URL (required for API calls)
import { URL, URLSearchParams } from "react-native-url-polyfill";
polyfillGlobal("URL", () => URL);
polyfillGlobal("URLSearchParams", () => URLSearchParams);Import in Root Layout
// app/_layout.tsx
import "../polyfills"; // Import before anything else
export default function RootLayout() {
return <Slot />;
}Environment Variables
Server Variables (.env.local)
# Authentication
BETTER_AUTH_SECRET="generate-32-character-secret-key-here"
BETTER_AUTH_URL="http://localhost:3000"
# AI
ANTHROPIC_API_KEY="sk-ant-..."
AI_GATEWAY_API_KEY="your-gateway-key" # Optional
# Database
DATABASE_URL="file:./local.db"
# OAuth (optional)
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
APPLE_CLIENT_ID="..."
APPLE_TEAM_ID="..."
APPLE_KEY_ID="..."
APPLE_PRIVATE_KEY="..."Client Variables (.env.local)
# Expo public variables (available to client)
EXPO_PUBLIC_API_BASE_URL="http://localhost:3000"
EXPO_PUBLIC_ENABLE_BIOMETRICS="true"
# Next.js public variables
NEXT_PUBLIC_API_BASE_URL="http://localhost:3000"Build Variables (eas.json)
{
"build": {
"development": {
"developmentClient": true,
"env": {
"EXPO_PUBLIC_API_BASE_URL": "http://localhost:3000"
}
},
"preview": {
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://staging.yourdomain.com"
}
},
"production": {
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://yourdomain.com"
}
}
}
}Building for Production
Web Build (Next.js)
# Build for web
pnpm --filter mobile-app build:web
# Output: .next/
# Deploy to Vercel, Netlify, or any static hostNative Build (EAS)
# Install EAS CLI
npm install -g eas-cli
# Login to Expo
eas login
# Configure EAS
eas build:configure
# Build for iOS
eas build --platform ios --profile production
# Build for Android
eas build --platform android --profile production
# Build both platforms
eas build --platform all --profile productionEAS Build Configuration
// eas.json
{
"cli": {
"version": ">= 13.2.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m-medium"
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": true
}
},
"production": {
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "your-apple-id@email.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCDE12345"
},
"android": {
"serviceAccountKeyPath": "./service-account-key.json",
"track": "internal"
}
}
}
}Scripts Reference
{
"scripts": {
"dev": "next dev",
"dev:web": "next dev",
"dev:native": "expo start",
"dev:tunnel": "expo start --tunnel",
"ios": "expo start --ios",
"android": "expo start --android",
"web": "expo start --web",
"build": "expo export",
"build:web": "next build",
"build:native": "eas build --platform all",
"prebuild": "expo prebuild",
"clean:metro": "rm -rf .expo node_modules/.cache",
"clean:all": "rm -rf .expo .next node_modules/.cache",
"lint": "eslint app pages components",
"typecheck": "tsc --noEmit"
}
}Command Descriptions:
dev— Start Next.js web server (default)dev:native— Start Expo dev serverdev:tunnel— Start Expo with tunnel (for remote devices)ios— Open in iOS Simulatorandroid— Open in Android Emulatorbuild:web— Build Next.js for productionbuild:native— Build native apps with EASprebuild— Generate native iOS/Android directoriesclean:metro— Clear Metro bundler cacheclean:all— Clear all caches
Testing
Type Checking
pnpm --filter mobile-app typecheckLinting
pnpm --filter mobile-app lintManual Testing
- Web:
pnpm --filter mobile-app dev→http://localhost:3000 - iOS:
pnpm --filter mobile-app ios→ Opens iOS Simulator - Android:
pnpm --filter mobile-app android→ Opens Android Emulator - Physical Device:
pnpm --filter mobile-app dev:tunnel→ Scan QR code in Expo Go app
Troubleshooting
Metro Bundler Cache Issues
Issue: Changes not reflecting or build errors.
Solution: Clear Metro cache:
pnpm --filter mobile-app clean:metro
pnpm --filter mobile-app dev:nativePolyfill Errors
Issue: TextEncoder is not defined or similar.
Solution: Ensure polyfills are imported first:
// app/_layout.tsx
import "../polyfills"; // MUST be first import
export default function RootLayout() {
return <Slot />;
}Deep Links Not Working
Issue: Deep links don't open app or navigate correctly.
Solution: Verify scheme in app.config.ts and test with:
# iOS
xcrun simctl openurl booted mobileapp://chat
# Android
adb shell am start -W -a android.intent.action.VIEW -d "mobileapp://chat"Biometric Authentication Fails
Issue: Biometric prompt doesn't appear.
Solution: Check permissions and device capabilities:
import * as LocalAuthentication from "expo-local-authentication";
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
console.log("Biometric Hardware:", hasHardware);
console.log("Biometrics Enrolled:", isEnrolled);Related Documentation
- oneapp-onstage — Web-only AI chat application
- @repo/uni-ui — Universal UI components (web + native)
- @repo/uni-app/native-bridge — WebView ↔ Native bridge API
- @repo/auth — Authentication utilities