Building Enterprise Mobile Apps with Expo and React Native

Enterprise mobile app development with Expo and React Native: EAS Build, push notifications, offline support, deep linking, authentication, and App Store submission.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.5k 語数|

Expo と React Native を使用してエンタープライズ モバイル アプリを構築する

Expo は、ラピッド プロトタイピング ツールから正規のエンタープライズ プラットフォームに成長しました。 Expo SDK 52 と新しいアーキテクチャ (ファブリック レンダラー + JSI) を使用すると、React Web アプリケーションとコードを共有し、単一の TypeScript コードベースから iOS と Android の両方に配布しながら、ネイティブ アプリのパフォーマンスと操作感に匹敵するクロスプラットフォームのモバイル アプリを構築できます。

このガイドでは、趣味のプロジェクトと実稼働のエンタープライズ アプリを区別するパターン (EAS ビルド構成、プッシュ通知インフラストラクチャ、オフラインファースト データ同期、生体認証、App Store 申請ワークフロー) について説明します。

重要なポイント

  • Expo Go はデモ用です。 EAS Build は本番環境向けです - 初日から EAS を使用して始めましょう
  • 新しいアーキテクチャ (ファブリック + JSI) はオプトインですが、目に見えるパフォーマンスの向上をもたらします
  • expo-notifications では、フォアグラウンドとバックグラウンドの両方の処理について慎重な設定が必要です
  • オフライン サポートには、同期ロジックを備えたローカル データベース (WatermelonDB または SQLite) が必要です
  • expo-local-authentication による生体認証は数行のコードです
  • ディープリンクには、app.json と認証プロバイダーのコールバック URL の両方で構成が必要です
  • TypeScript パス エイリアスは babel-plugin-module-resolver 経由で Expo で機能します
  • 機密データを AsyncStorage に保存しないでください — トークンには expo-secure-store を使用してください

プロジェクトのセットアップ

ネイティブ モジュールの要件が予想される場合は、すべての Expo プロジェクトをベア ワークフロー (マネージドではない) で開始します。マネージド ワークフローは便利ですが制限があり、後で切り替えるにはイジェクトが必要になるため、中断が生じます。

# Create new Expo project
npx create-expo-app@latest MyApp --template

# Or with TypeScript template
npx create-expo-app@latest MyApp -t expo-template-blank-typescript

# Install core enterprise dependencies
npx expo install \
  expo-router \
  expo-secure-store \
  expo-local-authentication \
  expo-notifications \
  expo-updates \
  expo-constants \
  @react-navigation/native \
  @react-navigation/bottom-tabs \
  @tanstack/react-query

expo-router ファイルベースのルーティング システムは最新のアプローチであり、Next.js の App Router パターンを反映しており、Web とモバイル間のコンテキスト切り替えがはるかに簡単になります。


app.json 構成

エンタープライズ アプリでは、最初から慎重な app.json 構成が必要です。

{
  "expo": {
    "name": "ECOSIRE",
    "slug": "ecosire-mobile",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "automatic",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#0a0a0a"
    },
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.ecosire.mobile",
      "buildNumber": "1",
      "infoPlist": {
        "NSFaceIDUsageDescription": "Use Face ID to securely sign in",
        "NSCameraUsageDescription": "Take photos for your profile"
      }
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#0a0a0a"
      },
      "package": "com.ecosire.mobile",
      "versionCode": 1,
      "permissions": [
        "USE_BIOMETRIC",
        "USE_FINGERPRINT",
        "RECEIVE_BOOT_COMPLETED",
        "VIBRATE"
      ]
    },
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#f59e0b",
          "sounds": ["./assets/notification.wav"]
        }
      ],
      "expo-router",
      "expo-secure-store",
      "expo-local-authentication"
    ],
    "updates": {
      "url": "https://u.expo.dev/YOUR-PROJECT-ID"
    },
    "runtimeVersion": {
      "policy": "sdkVersion"
    },
    "extra": {
      "eas": {
        "projectId": "YOUR-PROJECT-ID"
      }
    }
  }
}

EAS ビルド構成

EAS (Expo Application Services) は、アプリの構築、送信、更新を処理します。 eas.json を使用して構成します。

{
  "cli": {
    "version": ">= 10.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "android": {
        "buildType": "apk"
      },
      "env": {
        "API_URL": "http://localhost:3001",
        "NODE_ENV": "development"
      }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "env": {
        "API_URL": "https://staging-api.ecosire.com",
        "NODE_ENV": "staging"
      }
    },
    "production": {
      "channel": "production",
      "autoIncrement": true,
      "env": {
        "API_URL": "https://api.ecosire.com",
        "NODE_ENV": "production"
      },
      "ios": {
        "credentialsSource": "remote"
      },
      "android": {
        "credentialsSource": "remote"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "[email protected]",
        "ascAppId": "YOUR-APP-STORE-CONNECT-APP-ID",
        "appleTeamId": "YOUR-TEAM-ID"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}
# Build for different environments
eas build --profile development --platform ios
eas build --profile preview --platform all
eas build --profile production --platform all

# Submit to stores
eas submit --profile production --platform ios
eas submit --profile production --platform android

安全な認証

エンタープライズ アプリは、認証トークンを AsyncStorage に保存してはなりません。トークンは暗号化されておらず、root 化されたデバイス上の他のアプリからアクセスできます。キーチェーン (iOS) とキーストア (Android) を使用する expo-secure-store を使用します。

// lib/auth/token-storage.ts
import * as SecureStore from 'expo-secure-store';

const ACCESS_TOKEN_KEY = 'ecosire_access_token';
const REFRESH_TOKEN_KEY = 'ecosire_refresh_token';

export const tokenStorage = {
  async saveTokens(accessToken: string, refreshToken: string) {
    await Promise.all([
      SecureStore.setItemAsync(ACCESS_TOKEN_KEY, accessToken, {
        keychainService: 'com.ecosire.mobile.auth',
        keychainAccessible: SecureStore.WHEN_UNLOCKED,
      }),
      SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken, {
        keychainService: 'com.ecosire.mobile.auth',
        keychainAccessible: SecureStore.WHEN_UNLOCKED,
      }),
    ]);
  },

  async getAccessToken(): Promise<string | null> {
    return SecureStore.getItemAsync(ACCESS_TOKEN_KEY, {
      keychainService: 'com.ecosire.mobile.auth',
    });
  },

  async getRefreshToken(): Promise<string | null> {
    return SecureStore.getItemAsync(REFRESH_TOKEN_KEY, {
      keychainService: 'com.ecosire.mobile.auth',
    });
  },

  async clearTokens() {
    await Promise.all([
      SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY),
      SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
    ]);
  },
};

生体認証

Face ID / Touch ID / 指紋認証の追加:

// lib/auth/biometric.ts
import * as LocalAuthentication from 'expo-local-authentication';

export async function isBiometricAvailable(): Promise<boolean> {
  const compatible = await LocalAuthentication.hasHardwareAsync();
  if (!compatible) return false;

  const enrolled = await LocalAuthentication.isEnrolledAsync();
  return enrolled;
}

export async function authenticateWithBiometrics(
  reason = 'Confirm your identity'
): Promise<boolean> {
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: reason,
    fallbackLabel: 'Use passcode',
    disableDeviceFallback: false,
    cancelLabel: 'Cancel',
  });

  return result.success;
}

// Usage in a component
async function handleSensitiveAction() {
  const available = await isBiometricAvailable();

  if (available) {
    const authenticated = await authenticateWithBiometrics(
      'Confirm this transaction'
    );
    if (!authenticated) return;
  }

  // Proceed with the action
  await processSensitiveOperation();
}

プッシュ通知

プッシュ通知にはバックエンド インフラストラクチャと慎重な権限処理が必要です。

// lib/notifications/setup.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';

// Configure how notifications appear when app is in foreground
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotifications(): Promise<string | null> {
  // Push notifications don't work on simulators
  if (!Device.isDevice) {
    console.warn('Push notifications require a physical device');
    return 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; // User denied permissions
  }

  // Android requires a notification channel
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#f59e0b',
    });
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const token = await Notifications.getExpoPushTokenAsync({ projectId });

  return token.data;
}
// hooks/use-push-notifications.ts
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import * as Notifications from 'expo-notifications';
import { registerForPushNotifications } from '@/lib/notifications/setup';
import { updatePushToken } from '@/lib/api/notifications';

export function usePushNotifications() {
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    // Register and save token to backend
    registerForPushNotifications().then((token) => {
      if (token) {
        updatePushToken(token).catch(console.error);
      }
    });

    // Handle notifications received while app is open
    notificationListener.current =
      Notifications.addNotificationReceivedListener((notification) => {
        console.log('Notification received:', notification);
      });

    // Handle notification tap
    responseListener.current =
      Notifications.addNotificationResponseReceivedListener((response) => {
        const data = response.notification.request.content.data;
        // Navigate based on notification data
        handleNotificationNavigation(data);
      });

    return () => {
      notificationListener.current?.remove();
      responseListener.current?.remove();
    };
  }, []);
}

WatermelonDB によるオフライン サポート

エンタープライズ アプリはオフラインで動作する必要があります。 WatermelonDB は、React Native にとって最もパフォーマンスの高いローカル データベースです。

npx expo install @nozbe/watermelondb @nozbe/with-observables
// db/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'contacts',
      columns: [
        { name: 'server_id', type: 'string', isOptional: true },
        { name: 'name', type: 'string' },
        { name: 'email', type: 'string', isOptional: true },
        { name: 'organization_id', type: 'string' },
        { name: 'synced_at', type: 'number', isOptional: true },
        { name: 'is_deleted', type: 'boolean' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
  ],
});
// lib/sync/contacts-sync.ts
import { synchronize } from '@nozbe/watermelondb/sync';
import { database } from '@/db';

export async function syncContacts(authToken: string) {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt }) => {
      const response = await fetch(
        `${API_URL}/sync/contacts?since=${lastPulledAt}`,
        {
          headers: { Authorization: `Bearer ${authToken}` },
        }
      );
      const { changes, timestamp } = await response.json();
      return { changes, timestamp };
    },
    pushChanges: async ({ changes }) => {
      await fetch(`${API_URL}/sync/contacts`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${authToken}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ changes }),
      });
    },
    migrationsEnabledAtVersion: 1,
  });
}

TanStack API データのクエリ

TanStack Query は、React Web アプリとまったく同じように React Native で動作します。

// lib/api/contacts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tokenStorage } from '@/lib/auth/token-storage';

async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
  const token = await tokenStorage.getAccessToken();

  const response = await fetch(`${process.env.API_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options?.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

export function useContacts() {
  return useQuery({
    queryKey: ['contacts'],
    queryFn: () => apiRequest<Contact[]>('/contacts'),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

export function useCreateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateContactDto) =>
      apiRequest<Contact>('/contacts', {
        method: 'POST',
        body: JSON.stringify(data),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['contacts'] });
    },
  });
}

expo アップデートによる無線アップデート

EAS Update は、App Store のレビューなしで JavaScript バンドルのアップデートを配信します。これはバグ修正に重要です。

// hooks/use-app-updates.ts
import { useEffect } from 'react';
import * as Updates from 'expo-updates';
import { Alert } from 'react-native';

export function useAppUpdates() {
  useEffect(() => {
    async function checkForUpdates() {
      if (__DEV__) return; // Skip in development

      try {
        const update = await Updates.checkForUpdateAsync();

        if (update.isAvailable) {
          await Updates.fetchUpdateAsync();

          Alert.alert(
            'Update Available',
            'A new version of the app is ready. Restart to apply.',
            [
              { text: 'Later', style: 'cancel' },
              {
                text: 'Restart Now',
                onPress: () => Updates.reloadAsync(),
              },
            ]
          );
        }
      } catch (error) {
        // Update check failed — app continues running current version
        console.warn('Update check failed:', error);
      }
    }

    checkForUpdates();
  }, []);
}

よくある質問

素の React Native ではなく Expo を選択する必要があるのはどのような場合ですか?

大部分のエンタープライズ アプリには Expo を選択してください。 EAS エコシステムは、生体認証、プッシュ通知、ディープ リンク、カメラ、ファイル システム、安全なストレージなど、企業要件の 95% をカバーします。 Expo では利用できないカスタム ネイティブ モジュールが必要な場合、またはレガシー ネイティブ コードと統合する場合にのみ、裸の React Native を使用してください。 Expo を使用した開発エクスペリエンスは大幅に向上し、EAS ビルドはネイティブ ビルドの複雑さを処理します。

Next.js Web アプリと Expo モバイル アプリの間でコードを共有するにはどうすればよいですか?

ビジネス ロジック、タイプ、および API クライアントの共有パッケージを含む Turborepo モノリポジトリを使用します。 React Native はすべてのブラウザー/Node.js API をサポートしているわけではないため、共有パッケージに含まれる内容には注意してください。うまく機能するパターン: タイプ、検証スキーマ (Zod)、および API クライアント関数を共有しますが、UI コンポーネントを分離しておきます。 Next.js 16 の App Router と Expo Router は両方とも、同様のメンタル モデルでファイルベースのルーティングを使用するため、並列実装の維持が容易になります。

認証コールバックのディープリンクはどのように処理すればよいですか?

scheme の下の app.json で URI スキームを構成します (例: "scheme": "ecosire")。 ecosire://auth/callback を認証プロバイダーにリダイレクト URI として登録します。 expo-linking を使用して、アプリ内で受信 URL を処理します。実際の URL と同様に機能するユニバーサル リンク (iOS) とアプリ リンク (Android) の場合、追加のドメイン検証 (ドメインから提供される apple-app-site-association および assetlinks.json ファイル) が必要です。

EAS でアプリのバージョン管理を処理する最善の方法は何ですか?

eas.json"appVersionSource": "remote" を設定し、運用ビルドで "autoIncrement": true を有効にします。これにより、EAS はビルド番号を自動的に管理できるようになり、手動で増やすのを忘れるというよくある間違いを防ぐことができます。人間が読めるバージョン (1.2.3) を app.json に保持し、ビルド番号 (iOS buildNumber、Android versionCode) を EAS に処理させます。

開発環境でプッシュ通知をテストするにはどうすればよいですか?

デバイスの Expo プッシュ トークンを使用して、https://expo.dev/notifications にある Expo のプッシュ通知テスト ツールを使用します。ローカル開発の場合、expo-notifications は、バックエンドなしでローカル通知をトリガーする scheduleNotificationAsync 関数を提供します。エンドツーエンドのテストの場合は、eas build --profile development を使用して開発クライアントを構築し、物理デバイスを使用します。


次のステップ

信頼性が高く、安全で、大規模に保守可能なエンタープライズ モバイル アプリを構築するには、基盤を正しく構築する必要があります。EAS ビルド構成、安全なトークン ストレージ、プッシュ通知インフラストラクチャ、オフライン同期はすべて、初日から慎重なセットアップが必要です。

ECOSIRE は、11 画面、無限スクロール、プッシュ通知、および EAS デプロイメント構成を備えた Expo を使用して、実稼働モバイル アプリケーションを構築しました。モバイル開発の専門知識が必要な場合は、開発サービスをご覧ください

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット