OneApp Docs
Platform Apps

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 server

One 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:

FeatureMethodDescription
HapticsNativeBridge.haptic('success')Trigger haptic feedback
ClipboardNativeBridge.copyToClipboard(text)Copy to clipboard
ShareNativeBridge.share({ message, url })Open native share sheet
AuthNativeBridge.getAuthToken()Get auth token from secure storage
NavigationNativeBridge.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 dev

2. Start the native app

# Start Expo dev server
pnpm --filter mobile-app dev:native

# Then press 'i' for iOS or 'a' for Android

3. Create a cross-platform screen

app/(tabs)/profile.tsx (native) & pages/profile.tsx (web)
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 android

That'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

PropertyValue
Locationplatform/apps/mobile-app
Port3000 (web), dynamic (Expo)
FrameworkExpo 54 + Next.js 16
RoutingExpo Router (native), Next.js Pages (web)
StylingNativeWind v4 (Tailwind CSS for RN)
AuthBetter Auth with Expo plugin
AIVercel AI SDK v6
Tech StackReact 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 scripts

Platform 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;
// 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 />;
}
// 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=123

React 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 host

Native 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 production

EAS 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 server
  • dev:tunnel — Start Expo with tunnel (for remote devices)
  • ios — Open in iOS Simulator
  • android — Open in Android Emulator
  • build:web — Build Next.js for production
  • build:native — Build native apps with EAS
  • prebuild — Generate native iOS/Android directories
  • clean:metro — Clear Metro bundler cache
  • clean:all — Clear all caches

Testing

Type Checking

pnpm --filter mobile-app typecheck

Linting

pnpm --filter mobile-app lint

Manual Testing

  1. Web: pnpm --filter mobile-app devhttp://localhost:3000
  2. iOS: pnpm --filter mobile-app ios → Opens iOS Simulator
  3. Android: pnpm --filter mobile-app android → Opens Android Emulator
  4. 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:native

Polyfill 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 />;
}

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);
  • 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

On this page