Build a Cross-Platform Mobile App with Expo, React Native, and TypeScript in 2026

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

One codebase, two platforms. Expo has become the default way to build React Native apps in 2026. With file-based routing, native module support, and over-the-air updates, it removes the complexity of mobile development while keeping full native capabilities. In this tutorial, you'll build a complete task management app that runs on both iOS and Android.

What You'll Build

A full-featured TaskFlow app — a task management application with:

  • File-based navigation using Expo Router v4
  • Beautiful UI with NativeWind (Tailwind CSS for React Native)
  • Local data persistence with Expo SQLite
  • Camera integration for task attachments
  • Push notifications for task reminders
  • Dark mode support
  • Deployment-ready configuration

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • A smartphone (iOS or Android) with the Expo Go app installed
  • Basic knowledge of React and TypeScript
  • VS Code with the Expo Tools extension (recommended)
  • An Expo account (free at expo.dev)

Step 1: Create the Expo Project

Start by creating a new Expo project with the latest SDK:

npx create-expo-app@latest TaskFlow --template tabs
cd TaskFlow

This scaffolds a project with file-based routing already configured. Let's examine the structure:

TaskFlow/
├── app/                    # File-based routes
│   ├── (tabs)/             # Tab layout group
│   │   ├── _layout.tsx     # Tab navigator config
│   │   ├── index.tsx       # Home tab
│   │   └── explore.tsx     # Explore tab
│   ├── _layout.tsx         # Root layout
│   └── +not-found.tsx      # 404 page
├── assets/                 # Images, fonts
├── components/             # Reusable components
├── constants/              # Theme, config
└── package.json

Install additional dependencies we'll need:

npx expo install expo-sqlite expo-camera expo-notifications expo-image-picker nativewind tailwindcss

Step 2: Configure NativeWind (Tailwind CSS)

NativeWind brings Tailwind CSS utility classes to React Native. Set it up:

npx expo install nativewind tailwindcss react-native-reanimated

Create tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,jsx,ts,tsx}",
    "./components/**/*.{js,jsx,ts,tsx}",
  ],
  presets: [require("nativewind/preset")],
  theme: {
    extend: {
      colors: {
        primary: "#6366f1",
        secondary: "#8b5cf6",
      },
    },
  },
  plugins: [],
};

Create global.css in the project root:

@tailwind base;
@tailwind components;
@tailwind utilities;

Update babel.config.js to include the NativeWind plugin:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};

Now update app/_layout.tsx to import the global styles:

import "../global.css";
import { Stack } from "expo-router";
import { useColorScheme } from "react-native";
import {
  DarkTheme,
  DefaultTheme,
  ThemeProvider,
} from "@react-navigation/native";
 
export default function RootLayout() {
  const colorScheme = useColorScheme();
 
  return (
    <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="+not-found" />
      </Stack>
    </ThemeProvider>
  );
}

Step 3: Set Up the Database with Expo SQLite

Create lib/database.ts for local data persistence:

import * as SQLite from "expo-sqlite";
 
const db = SQLite.openDatabaseSync("taskflow.db");
 
export interface Task {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  priority: "low" | "medium" | "high";
  imageUri?: string;
  createdAt: string;
  dueDate?: string;
}
 
export function initDatabase(): void {
  db.execSync(`
    CREATE TABLE IF NOT EXISTS tasks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      description TEXT DEFAULT '',
      completed INTEGER DEFAULT 0,
      priority TEXT DEFAULT 'medium',
      imageUri TEXT,
      createdAt TEXT DEFAULT (datetime('now')),
      dueDate TEXT
    );
  `);
}
 
export function getTasks(): Task[] {
  const results = db.getAllSync(
    "SELECT * FROM tasks ORDER BY completed ASC, createdAt DESC"
  );
  return results.map((row: any) => ({
    ...row,
    completed: Boolean(row.completed),
  }));
}
 
export function addTask(
  title: string,
  description: string,
  priority: string,
  dueDate?: string
): void {
  db.runSync(
    "INSERT INTO tasks (title, description, priority, dueDate) VALUES (?, ?, ?, ?)",
    [title, description, priority, dueDate ?? null]
  );
}
 
export function toggleTask(id: number, completed: boolean): void {
  db.runSync("UPDATE tasks SET completed = ? WHERE id = ?", [
    completed ? 1 : 0,
    id,
  ]);
}
 
export function deleteTask(id: number): void {
  db.runSync("DELETE FROM tasks WHERE id = ?", [id]);
}
 
export function updateTaskImage(id: number, imageUri: string): void {
  db.runSync("UPDATE tasks SET imageUri = ? WHERE id = ?", [imageUri, id]);
}

Initialize the database in app/_layout.tsx:

import { useEffect } from "react";
import { initDatabase } from "@/lib/database";
 
export default function RootLayout() {
  const colorScheme = useColorScheme();
 
  useEffect(() => {
    initDatabase();
  }, []);
 
  // ... rest of the layout
}

Step 4: Build the Tab Navigation

Update app/(tabs)/_layout.tsx for our task app tabs:

import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useColorScheme } from "react-native";
 
export default function TabLayout() {
  const colorScheme = useColorScheme();
 
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: "#6366f1",
        tabBarStyle: {
          backgroundColor: colorScheme === "dark" ? "#1a1a2e" : "#ffffff",
        },
        headerStyle: {
          backgroundColor: colorScheme === "dark" ? "#1a1a2e" : "#ffffff",
        },
        headerTintColor: colorScheme === "dark" ? "#ffffff" : "#000000",
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: "Tasks",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="list" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="add"
        options={{
          title: "Add Task",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="add-circle" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="stats"
        options={{
          title: "Stats",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="stats-chart" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Step 5: Create the Task List Screen

Replace app/(tabs)/index.tsx:

import { View, Text, FlatList, Pressable, RefreshControl } from "react-native";
import { useState, useCallback } from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { getTasks, toggleTask, deleteTask, Task } from "@/lib/database";
 
function PriorityBadge({ priority }: { priority: string }) {
  const colors = {
    high: "bg-red-100 text-red-700",
    medium: "bg-yellow-100 text-yellow-700",
    low: "bg-green-100 text-green-700",
  };
 
  return (
    <View className={`px-2 py-1 rounded-full ${colors[priority as keyof typeof colors]}`}>
      <Text className={`text-xs font-medium ${colors[priority as keyof typeof colors]}`}>
        {priority}
      </Text>
    </View>
  );
}
 
function TaskItem({
  task,
  onToggle,
  onDelete,
}: {
  task: Task;
  onToggle: () => void;
  onDelete: () => void;
}) {
  return (
    <View className="bg-white dark:bg-gray-800 mx-4 mb-3 p-4 rounded-2xl shadow-sm flex-row items-center">
      <Pressable onPress={onToggle} className="mr-3">
        <Ionicons
          name={task.completed ? "checkmark-circle" : "ellipse-outline"}
          size={28}
          color={task.completed ? "#22c55e" : "#9ca3af"}
        />
      </Pressable>
 
      <View className="flex-1">
        <Text
          className={`text-base font-semibold dark:text-white ${
            task.completed ? "line-through text-gray-400" : ""
          }`}
        >
          {task.title}
        </Text>
        {task.description ? (
          <Text className="text-sm text-gray-500 mt-1">{task.description}</Text>
        ) : null}
        <View className="flex-row items-center mt-2 gap-2">
          <PriorityBadge priority={task.priority} />
          {task.dueDate && (
            <Text className="text-xs text-gray-400">
              Due: {new Date(task.dueDate).toLocaleDateString()}
            </Text>
          )}
        </View>
      </View>
 
      <Pressable onPress={onDelete} className="ml-2 p-2">
        <Ionicons name="trash-outline" size={20} color="#ef4444" />
      </Pressable>
    </View>
  );
}
 
export default function TasksScreen() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [refreshing, setRefreshing] = useState(false);
 
  const loadTasks = useCallback(() => {
    const data = getTasks();
    setTasks(data);
  }, []);
 
  useFocusEffect(
    useCallback(() => {
      loadTasks();
    }, [loadTasks])
  );
 
  const onRefresh = useCallback(() => {
    setRefreshing(true);
    loadTasks();
    setRefreshing(false);
  }, [loadTasks]);
 
  const handleToggle = (id: number, completed: boolean) => {
    toggleTask(id, !completed);
    loadTasks();
  };
 
  const handleDelete = (id: number) => {
    deleteTask(id);
    loadTasks();
  };
 
  const completedCount = tasks.filter((t) => t.completed).length;
 
  return (
    <View className="flex-1 bg-gray-50 dark:bg-gray-900">
      <View className="px-4 pt-4 pb-2">
        <Text className="text-2xl font-bold dark:text-white">My Tasks</Text>
        <Text className="text-sm text-gray-500 mt-1">
          {completedCount}/{tasks.length} completed
        </Text>
      </View>
 
      <FlatList
        data={tasks}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <TaskItem
            task={item}
            onToggle={() => handleToggle(item.id, item.completed)}
            onDelete={() => handleDelete(item.id)}
          />
        )}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
        }
        contentContainerStyle={{ paddingTop: 8, paddingBottom: 100 }}
        ListEmptyComponent={
          <View className="items-center justify-center pt-20">
            <Ionicons name="clipboard-outline" size={64} color="#d1d5db" />
            <Text className="text-gray-400 text-lg mt-4">No tasks yet</Text>
            <Text className="text-gray-400 text-sm mt-1">
              Tap the + tab to add your first task
            </Text>
          </View>
        }
      />
    </View>
  );
}

Step 6: Create the Add Task Screen

Create app/(tabs)/add.tsx:

import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
import { useState } from "react";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { addTask } from "@/lib/database";
 
const priorities = [
  { value: "low", label: "Low", color: "bg-green-500", icon: "arrow-down" },
  { value: "medium", label: "Medium", color: "bg-yellow-500", icon: "remove" },
  { value: "high", label: "High", color: "bg-red-500", icon: "arrow-up" },
] as const;
 
export default function AddTaskScreen() {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [priority, setPriority] = useState<string>("medium");
 
  const handleSubmit = () => {
    if (!title.trim()) {
      Alert.alert("Error", "Task title is required");
      return;
    }
 
    addTask(title.trim(), description.trim(), priority);
    Alert.alert("Success", "Task created!", [
      { text: "OK", onPress: () => router.navigate("/(tabs)") },
    ]);
    setTitle("");
    setDescription("");
    setPriority("medium");
  };
 
  return (
    <ScrollView className="flex-1 bg-gray-50 dark:bg-gray-900">
      <View className="p-4">
        <Text className="text-2xl font-bold mb-6 dark:text-white">
          New Task
        </Text>
 
        {/* Title Input */}
        <View className="mb-4">
          <Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
            Title *
          </Text>
          <TextInput
            value={title}
            onChangeText={setTitle}
            placeholder="What needs to be done?"
            placeholderTextColor="#9ca3af"
            className="bg-white dark:bg-gray-800 p-4 rounded-xl text-base dark:text-white border border-gray-200 dark:border-gray-700"
          />
        </View>
 
        {/* Description Input */}
        <View className="mb-4">
          <Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
            Description
          </Text>
          <TextInput
            value={description}
            onChangeText={setDescription}
            placeholder="Add details..."
            placeholderTextColor="#9ca3af"
            multiline
            numberOfLines={4}
            textAlignVertical="top"
            className="bg-white dark:bg-gray-800 p-4 rounded-xl text-base dark:text-white border border-gray-200 dark:border-gray-700 min-h-[120px]"
          />
        </View>
 
        {/* Priority Selector */}
        <View className="mb-6">
          <Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
            Priority
          </Text>
          <View className="flex-row gap-3">
            {priorities.map((p) => (
              <Pressable
                key={p.value}
                onPress={() => setPriority(p.value)}
                className={`flex-1 flex-row items-center justify-center p-3 rounded-xl border-2 ${
                  priority === p.value
                    ? "border-primary bg-primary/10"
                    : "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
                }`}
              >
                <Ionicons
                  name={p.icon as any}
                  size={16}
                  color={priority === p.value ? "#6366f1" : "#9ca3af"}
                />
                <Text
                  className={`ml-1 font-medium ${
                    priority === p.value
                      ? "text-primary"
                      : "text-gray-500"
                  }`}
                >
                  {p.label}
                </Text>
              </Pressable>
            ))}
          </View>
        </View>
 
        {/* Submit Button */}
        <Pressable
          onPress={handleSubmit}
          className="bg-primary p-4 rounded-xl items-center active:opacity-80"
        >
          <Text className="text-white font-bold text-base">Create Task</Text>
        </Pressable>
      </View>
    </ScrollView>
  );
}

Step 7: Create the Statistics Screen

Create app/(tabs)/stats.tsx:

import { View, Text, ScrollView } from "react-native";
import { useState, useCallback } from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { getTasks, Task } from "@/lib/database";
 
function StatCard({
  icon,
  label,
  value,
  color,
}: {
  icon: string;
  label: string;
  value: number | string;
  color: string;
}) {
  return (
    <View className="bg-white dark:bg-gray-800 p-4 rounded-2xl flex-1 items-center shadow-sm">
      <Ionicons name={icon as any} size={28} color={color} />
      <Text className="text-2xl font-bold mt-2 dark:text-white">{value}</Text>
      <Text className="text-xs text-gray-500 mt-1">{label}</Text>
    </View>
  );
}
 
export default function StatsScreen() {
  const [tasks, setTasks] = useState<Task[]>([]);
 
  useFocusEffect(
    useCallback(() => {
      setTasks(getTasks());
    }, [])
  );
 
  const total = tasks.length;
  const completed = tasks.filter((t) => t.completed).length;
  const pending = total - completed;
  const highPriority = tasks.filter(
    (t) => t.priority === "high" && !t.completed
  ).length;
  const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
 
  return (
    <ScrollView className="flex-1 bg-gray-50 dark:bg-gray-900 p-4">
      <Text className="text-2xl font-bold mb-6 dark:text-white">
        Statistics
      </Text>
 
      <View className="flex-row gap-3 mb-3">
        <StatCard
          icon="list"
          label="Total"
          value={total}
          color="#6366f1"
        />
        <StatCard
          icon="checkmark-circle"
          label="Done"
          value={completed}
          color="#22c55e"
        />
      </View>
 
      <View className="flex-row gap-3 mb-6">
        <StatCard
          icon="time"
          label="Pending"
          value={pending}
          color="#f59e0b"
        />
        <StatCard
          icon="alert-circle"
          label="High Priority"
          value={highPriority}
          color="#ef4444"
        />
      </View>
 
      {/* Completion Rate */}
      <View className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm">
        <Text className="text-lg font-semibold mb-4 dark:text-white">
          Completion Rate
        </Text>
        <View className="h-4 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
          <View
            className="h-full bg-primary rounded-full"
            style={{ width: `${completionRate}%` }}
          />
        </View>
        <Text className="text-center text-2xl font-bold mt-3 text-primary">
          {completionRate}%
        </Text>
      </View>
 
      {/* Priority Breakdown */}
      <View className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm mt-4">
        <Text className="text-lg font-semibold mb-4 dark:text-white">
          Priority Breakdown
        </Text>
        {(["high", "medium", "low"] as const).map((p) => {
          const count = tasks.filter((t) => t.priority === p).length;
          const pct = total > 0 ? Math.round((count / total) * 100) : 0;
          const colors = {
            high: "#ef4444",
            medium: "#f59e0b",
            low: "#22c55e",
          };
          return (
            <View key={p} className="flex-row items-center mb-3">
              <Text className="w-20 capitalize text-gray-600 dark:text-gray-400">
                {p}
              </Text>
              <View className="flex-1 h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mx-3">
                <View
                  className="h-full rounded-full"
                  style={{
                    width: `${pct}%`,
                    backgroundColor: colors[p],
                  }}
                />
              </View>
              <Text className="text-sm text-gray-500 w-8">{count}</Text>
            </View>
          );
        })}
      </View>
    </ScrollView>
  );
}

Step 8: Add Camera Integration for Task Attachments

Create app/camera.tsx for a camera modal:

import { View, Text, Pressable } from "react-native";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useRef } from "react";
import { router, useLocalSearchParams } from "expo-router";
import { updateTaskImage } from "@/lib/database";
 
export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const cameraRef = useRef<CameraView>(null);
  const { taskId } = useLocalSearchParams<{ taskId: string }>();
 
  if (!permission) return <View />;
 
  if (!permission.granted) {
    return (
      <View className="flex-1 items-center justify-center bg-black">
        <Text className="text-white text-lg mb-4">
          Camera permission is required
        </Text>
        <Pressable
          onPress={requestPermission}
          className="bg-primary px-6 py-3 rounded-xl"
        >
          <Text className="text-white font-bold">Grant Permission</Text>
        </Pressable>
      </View>
    );
  }
 
  const takePicture = async () => {
    if (!cameraRef.current) return;
 
    const photo = await cameraRef.current.takePictureAsync();
    if (photo && taskId) {
      updateTaskImage(parseInt(taskId), photo.uri);
      router.back();
    }
  };
 
  return (
    <View className="flex-1">
      <CameraView ref={cameraRef} className="flex-1" facing="back">
        <View className="flex-1 justify-end items-center pb-10">
          <Pressable
            onPress={takePicture}
            className="w-20 h-20 rounded-full bg-white border-4 border-gray-300 active:opacity-70"
          />
        </View>
      </CameraView>
    </View>
  );
}

Add the camera route to the root layout stack:

<Stack.Screen
  name="camera"
  options={{
    presentation: "fullScreenModal",
    headerShown: false,
  }}
/>

Step 9: Set Up Push Notifications

Create lib/notifications.ts:

import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
 
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});
 
export async function registerForPushNotifications(): Promise<string | null> {
  const { status: existingStatus } =
    await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;
 
  if (existingStatus !== "granted") {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }
 
  if (finalStatus !== "granted") {
    return null;
  }
 
  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("default", {
      name: "default",
      importance: Notifications.AndroidImportance.MAX,
    });
  }
 
  const token = (await Notifications.getExpoPushTokenAsync()).data;
  return token;
}
 
export async function scheduleTaskReminder(
  taskTitle: string,
  dueDate: Date
): Promise<string> {
  const trigger = new Date(dueDate);
  trigger.setHours(trigger.getHours() - 1); // Remind 1 hour before
 
  const id = await Notifications.scheduleNotificationAsync({
    content: {
      title: "Task Reminder",
      body: `"${taskTitle}" is due soon!`,
      sound: true,
    },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.DATE,
      date: trigger,
    },
  });
 
  return id;
}

Step 10: Configure for Production

Update app.json for production deployment:

{
  "expo": {
    "name": "TaskFlow",
    "slug": "taskflow",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/images/icon.png",
    "scheme": "taskflow",
    "userInterfaceStyle": "automatic",
    "newArchEnabled": true,
    "splash": {
      "image": "./assets/images/splash-icon.png",
      "resizeMode": "contain",
      "backgroundColor": "#6366f1"
    },
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.yourcompany.taskflow",
      "infoPlist": {
        "NSCameraUsageDescription": "TaskFlow uses the camera to attach photos to tasks."
      }
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/images/adaptive-icon.png",
        "backgroundColor": "#6366f1"
      },
      "package": "com.yourcompany.taskflow",
      "permissions": ["CAMERA", "RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"]
    },
    "plugins": [
      "expo-router",
      "expo-camera",
      "expo-notifications",
      "expo-sqlite"
    ]
  }
}

Step 11: Build and Deploy with EAS

Install EAS CLI and configure builds:

npm install -g eas-cli
eas login
eas build:configure

This creates eas.json:

{
  "cli": {
    "version": ">= 3.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {}
  },
  "submit": {
    "production": {}
  }
}

Build for both platforms:

# Development build (for testing on real devices)
eas build --profile development --platform all
 
# Production build
eas build --profile production --platform all

Submit to app stores:

# iOS App Store
eas submit --platform ios
 
# Google Play Store
eas submit --platform android

Step 12: Enable Over-the-Air Updates

One of Expo's most powerful features is OTA updates — push JavaScript updates without going through app store review:

npx expo install expo-updates

Add to app.json:

{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id"
    },
    "runtimeVersion": {
      "policy": "appVersion"
    }
  }
}

Push an update:

eas update --branch production --message "Fix task sorting bug"

Users receive the update on next app launch — no app store review needed.


Testing Your Implementation

Start the development server:

npx expo start

Test on your device:

  1. iOS: Scan the QR code with your Camera app
  2. Android: Scan the QR code with the Expo Go app

Test the following flows:

  • Create a new task with different priorities
  • Mark tasks as complete/incomplete
  • Delete a task
  • Check the statistics page updates correctly
  • Test dark mode by toggling your device settings
  • Pull to refresh the task list

Troubleshooting

"Metro bundler is not running"

npx expo start --clear

NativeWind styles not applying

Make sure global.css is imported in app/_layout.tsx and babel.config.js includes the NativeWind preset.

SQLite errors on web

Expo SQLite only works on iOS and Android. For web support, consider expo-sqlite/next or a different storage solution.

Camera not working in Expo Go

Some native modules require a development build. Run:

eas build --profile development --platform ios

Next Steps

Now that you have a working mobile app, consider these enhancements:

  • Cloud sync — Add Supabase or Firebase for cross-device synchronization
  • Biometric auth — Use expo-local-authentication for fingerprint/face ID
  • Widgets — Create home screen widgets with expo-widgets
  • Animations — Add transitions with react-native-reanimated
  • Offline-first — Implement a sync queue for when the device is offline

Conclusion

You've built a complete cross-platform mobile app using Expo SDK 52, React Native, and TypeScript. The app includes file-based routing with Expo Router, local data persistence with SQLite, camera integration, push notifications, and is ready for deployment to both app stores.

Expo has matured significantly in 2026, making it the go-to choice for React developers entering the mobile space. With EAS Build and OTA updates, the deployment workflow is as smooth as deploying a web application. The combination of React Native's performance with Expo's developer experience makes building production mobile apps faster than ever.


Want to read more tutorials? Check out our latest tutorial on Building a RAG Chatbot with Supabase pgvector and Next.js.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles