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
|19 de marzo de 202610 min de lectura2.2k Palabras|

Creación de aplicaciones móviles empresariales con Expo y React Native

Expo ha pasado de ser una herramienta de creación rápida de prototipos a una plataforma empresarial legítima. Con Expo SDK 52 y la nueva arquitectura (representador Fabric + JSI), puede crear aplicaciones móviles multiplataforma que igualen el rendimiento y la sensación de las aplicaciones nativas, mientras comparte código con su aplicación web React y lo envía a iOS y Android desde una única base de código TypeScript.

Esta guía cubre los patrones que separan un proyecto de hobby de una aplicación empresarial de producción: configuración de compilación EAS, infraestructura de notificaciones push, sincronización de datos sin conexión, autenticación biométrica y flujos de trabajo de envío de App Store.

Conclusiones clave

  • Expo Go es para demostraciones; EAS Build es para producción: comience con EAS desde el primer día
  • La nueva arquitectura (Fabric + JSI) es opcional pero ofrece mejoras de rendimiento mensurables
  • expo-notifications requiere una configuración cuidadosa para el manejo tanto en primer plano como en segundo plano
  • El soporte sin conexión necesita una base de datos local (WatermelonDB o SQLite) con lógica de sincronización
  • La autenticación biométrica con expo-local-authentication son unas pocas líneas de código
  • Los enlaces profundos requieren configuración tanto en app.json como en las URL de devolución de llamada de su proveedor de autenticación
  • Los alias de ruta de TypeScript funcionan en Expo a través de babel-plugin-module-resolver
  • Nunca almacene datos confidenciales en AsyncStorage; use expo-secure-store para tokens

Configuración del proyecto

Inicie cada proyecto de Expo con el flujo de trabajo básico (no administrado) si anticipa algún requisito del módulo nativo. El flujo de trabajo administrado es conveniente pero lo limita: cambiar más tarde requiere expulsión, lo cual es perjudicial.

# 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

El sistema de enrutamiento basado en archivos expo-router es el enfoque moderno: refleja los patrones de App Router de Next.js, lo que facilita mucho el cambio de contexto entre la web y el móvil.


Configuración de app.json

Las aplicaciones empresariales necesitan una configuración app.json cuidadosa desde el principio:

{
  "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"
      }
    }
  }
}

Configuración de compilación EAS

EAS (Expo Application Services) se encarga de crear, enviar y actualizar su aplicación. Configúrelo con 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

Autenticación segura

Las aplicaciones empresariales nunca deben almacenar tokens de autenticación en AsyncStorage: no están cifrados y son accesibles para otras aplicaciones en dispositivos rooteados. Utilice expo-secure-store que utiliza Keychain (iOS) y Keystore (Android):

// 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),
    ]);
  },
};

Autenticación biométrica

Agregar Face ID/Touch ID/autenticación de huellas dactilares:

// 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();
}

Notificaciones automáticas

Las notificaciones automáticas requieren una infraestructura de backend y un manejo cuidadoso de los permisos:

// 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();
    };
  }, []);
}

Soporte sin conexión con WatermelonDB

Las aplicaciones empresariales deben funcionar sin conexión. WatermelonDB es la base de datos local de mayor rendimiento para 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,
  });
}

Consulta TanStack para datos API

TanStack Query funciona en React Native exactamente como en las aplicaciones web de React:

// 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'] });
    },
  });
}

Actualizaciones inalámbricas con actualizaciones de exposición

EAS Update ofrece actualizaciones del paquete JavaScript sin revisión de la App Store, algo fundamental para corregir errores:

// 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();
  }, []);
}

Preguntas frecuentes

¿Cuándo debo elegir Expo en lugar de React Native?

Elija Expo para la gran mayoría de aplicaciones empresariales. El ecosistema EAS cubre el 95 % de los requisitos empresariales: autenticación biométrica, notificaciones automáticas, enlaces profundos, cámara, sistema de archivos y almacenamiento seguro. Utilice React Native solo si necesita módulos nativos personalizados que no están disponibles en Expo o si se está integrando con código nativo heredado. La experiencia de desarrollo con Expo es significativamente mejor y EAS Build maneja la complejidad de las compilaciones nativas.

¿Cómo comparto código entre una aplicación web Next.js y una aplicación móvil Expo?

Utilice un monorepo Turborepo con paquetes compartidos para lógica empresarial, tipos y clientes API. React Native no es compatible con todas las API de navegador/Node.js, así que tenga cuidado con lo que incluye los paquetes compartidos. Un patrón que funciona bien: comparte tipos, esquemas de validación (Zod) y funciones de cliente API, pero mantiene los componentes de la interfaz de usuario separados. App Router y Expo Router de Next.js 16 utilizan enrutamiento basado en archivos con modelos mentales similares, lo que facilita el mantenimiento de implementaciones paralelas.

¿Cómo manejo los enlaces profundos para las devoluciones de llamadas de autenticación?

Configure su esquema de URI en app.json bajo scheme (por ejemplo, "scheme": "ecosire"). Registre ecosire://auth/callback como URI de redireccionamiento en su proveedor de autenticación. Utilice expo-linking para manejar las URL entrantes en su aplicación. Para enlaces universales (iOS) y enlaces de aplicaciones (Android) que funcionan como URL reales, necesita verificación de dominio adicional: archivos apple-app-site-association y assetlinks.json servidos desde su dominio.

¿Cuál es la mejor manera de manejar el control de versiones de aplicaciones en EAS?

Establezca "appVersionSource": "remote" en eas.json y habilite "autoIncrement": true para compilaciones de producción. Esto permite a EAS administrar los números de compilación automáticamente, evitando el error común de olvidarse de incrementarlos manualmente. Mantenga su versión legible por humanos (1.2.3) en app.json y deje que EAS maneje el número de compilación (iOS buildNumber, Android versionCode).

¿Cómo pruebo las notificaciones push en desarrollo?

Utilice la herramienta de prueba de notificaciones push de Expo en https://expo.dev/notifications con el token push de Expo de su dispositivo. Para el desarrollo local, expo-notifications proporciona una función scheduleNotificationAsync que activa notificaciones locales sin un backend. Para pruebas de un extremo a otro, cree un cliente de desarrollo con eas build --profile development y utilice un dispositivo físico.


Próximos pasos

Crear una aplicación móvil empresarial que sea confiable, segura y mantenible a escala requiere tener las bases adecuadas: la configuración de EAS Build, el almacenamiento seguro de tokens, la infraestructura de notificaciones push y la sincronización fuera de línea necesitan una configuración cuidadosa desde el primer día.

ECOSIRE ha creado aplicaciones móviles de producción utilizando Expo con 11 pantallas, desplazamiento infinito, notificaciones automáticas y configuración de implementación de EAS. Si necesita experiencia en desarrollo móvil, explore nuestros servicios de desarrollo.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp