← Volver al blog
·8 min de lectura

Crear una app movil con Next.js y Capacitor: de web a nativa

Next.jsMobileCapacitor

Por que Next.js y Capacitor

Cuando necesitas una app movil pero tu equipo domina tecnologias web, las opciones clasicas son React Native o Flutter. Ambas requieren aprender paradigmas nuevos: componentes nativos en lugar de HTML, estilos propios en lugar de CSS, y herramientas de build completamente distintas. Hay una tercera via que se discute menos: tomar una aplicacion web existente y empaquetarla como app nativa con acceso a APIs del dispositivo.

Capacitor, creado por el equipo de Ionic, hace exactamente esto. A diferencia de Cordova (su predecesor), Capacitor trata el proyecto nativo como un artefacto de primera clase: genera proyectos reales de Android Studio y Xcode que puedes modificar directamente. No es un WebView opaco; es un proyecto nativo con un WebView como capa de presentacion.

La combinacion con Next.js es particularmente interesante cuando usas output: "export" para generar un sitio completamente estatico. Capacitor toma esos archivos HTML, CSS y JavaScript generados y los sirve desde el WebView nativo. El resultado es una app que se siente rapida porque los assets son locales (no hay descarga de red) y tiene acceso a Geolocation, Camera, Filesystem y docenas de APIs nativas.

Esta es la arquitectura que use para construir MiFarmaciApp, una aplicacion de farmacias de turno en Chile que funciona como web progresiva y como app nativa en Android e iOS, compartiendo el 100% del codigo de la interfaz.

Configuracion inicial

Partimos de un proyecto Next.js existente. El requisito fundamental es que soporte exportacion estatica:

// next.config.ts
const nextConfig = {
  output: "export",
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

La propiedad images.unoptimized es necesaria porque el optimizador de imagenes de Next.js requiere un servidor Node.js, que no existe en el contexto de Capacitor.

Instalamos Capacitor y sus dependencias:

npm install @capacitor/core @capacitor/cli
npx cap init "MiApp" "com.ejemplo.miapp" --web-dir out

El parametro --web-dir out es clave: le dice a Capacitor que los archivos web estan en el directorio out/, que es donde Next.js genera la exportacion estatica.

Ahora agregamos las plataformas nativas:

npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios

Esto genera los directorios android/ e ios/ con proyectos nativos completos. Estos directorios deben incluirse en el repositorio, a diferencia de Cordova donde se generaban cada vez.

Configuracion de capacitor.config.ts

El archivo de configuracion central controla el comportamiento del WebView y las integraciones nativas:

import type { CapacitorConfig } from "@capacitor/cli";

const config: CapacitorConfig = {
  appId: "com.ejemplo.miapp",
  appName: "MiApp",
  webDir: "out",
  server: {
    androidScheme: "https",
    iosScheme: "https",
  },
  plugins: {
    SplashScreen: {
      launchAutoHide: false,
      backgroundColor: "#0e0d0c",
      androidScaleType: "CENTER_CROP",
      showSpinner: false,
    },
    StatusBar: {
      style: "DARK",
      backgroundColor: "#0e0d0c",
    },
  },
};

export default config;

La propiedad androidScheme: "https" es importante para que las APIs web que requieren contexto seguro (como Geolocation) funcionen correctamente dentro del WebView. Sin esto, el esquema por defecto es http y ciertas APIs del navegador se bloquean.

Plugins nativos

La verdadera ventaja de Capacitor sobre una PWA es el acceso a APIs nativas mediante plugins. Cada plugin tiene una interfaz TypeScript identica en ambas plataformas.

Geolocation

npm install @capacitor/geolocation
npx cap sync
import { Geolocation } from "@capacitor/geolocation";

async function obtenerUbicacion() {
  const permisos = await Geolocation.checkPermissions();

  if (permisos.location !== "granted") {
    await Geolocation.requestPermissions();
  }

  const posicion = await Geolocation.getCurrentPosition({
    enableHighAccuracy: true,
    timeout: 10000,
  });

  return {
    lat: posicion.coords.latitude,
    lng: posicion.coords.longitude,
  };
}

En Android, necesitas agregar el permiso en android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

Camera

npm install @capacitor/camera
npx cap sync
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";

async function tomarFoto() {
  const imagen = await Camera.getPhoto({
    quality: 80,
    allowEditing: false,
    resultType: CameraResultType.Base64,
    source: CameraSource.Camera,
  });

  return `data:image/${imagen.format};base64,${imagen.base64String}`;
}

SplashScreen

El splash screen se controla programaticamente, permitiendo ocultarlo cuando la app esta lista:

import { SplashScreen } from "@capacitor/splash-screen";

// En el componente raiz de la app
useEffect(() => {
  const inicializar = async () => {
    await cargarDatosIniciales();
    await SplashScreen.hide({ fadeOutDuration: 300 });
  };

  inicializar();
}, []);

Detectar la plataforma y adaptar la UI

No toda la interfaz web funciona igual en un contexto movil. Los gestos son distintos, las areas seguras (notch, barra de navegacion) ocupan espacio, y ciertas interacciones deben adaptarse.

Capacitor expone la plataforma actual:

import { Capacitor } from "@capacitor/core";

const esNativa = Capacitor.isNativePlatform();
const plataforma = Capacitor.getPlatform(); // 'android', 'ios', 'web'

Un hook personalizado facilita la adaptacion:

import { useState, useEffect } from "react";
import { Capacitor } from "@capacitor/core";

export function usePlataforma() {
  const [info, setInfo] = useState({
    esNativa: false,
    plataforma: "web" as "android" | "ios" | "web",
  });

  useEffect(() => {
    setInfo({
      esNativa: Capacitor.isNativePlatform(),
      plataforma: Capacitor.getPlatform() as "android" | "ios" | "web",
    });
  }, []);

  return info;
}

Para las areas seguras en iOS (notch del iPhone), usamos las variables CSS nativas del WebView:

.header {
  padding-top: env(safe-area-inset-top);
}

.tab-bar {
  padding-bottom: env(safe-area-inset-bottom);
}

Y en el index.html generado, el viewport debe incluir viewport-fit=cover:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, viewport-fit=cover"
/>

Build para Android

El flujo de build para Android requiere tener instalado Android Studio con el SDK correspondiente.

# Generar la exportacion estatica de Next.js
npm run build

# Copiar los archivos web al proyecto nativo
npx cap sync android

# Abrir en Android Studio
npx cap open android

Desde Android Studio puedes ejecutar en un emulador o dispositivo fisico. Para generar un APK de distribucion:

  1. Menu Build > Generate Signed Bundle / APK
  2. Seleccionar APK o Android App Bundle (AAB, requerido para Google Play)
  3. Crear o seleccionar un keystore para firmar la app
  4. Elegir el build variant release

Tambien puedes generar el APK desde la linea de comandos:

cd android
./gradlew assembleRelease

El APK resultante estara en android/app/build/outputs/apk/release/.

Para automatizar esto en CI, un workflow de GitHub Actions seria:

- name: Build Next.js
  run: npm run build

- name: Sync Capacitor
  run: npx cap sync android

- name: Build APK
  working-directory: android
  run: ./gradlew assembleRelease

- name: Upload APK
  uses: actions/upload-artifact@v4
  with:
    name: app-release.apk
    path: android/app/build/outputs/apk/release/app-release-unsigned.apk

Build para iOS

iOS requiere macOS con Xcode instalado. No es posible compilar para iOS desde Windows o Linux.

npm run build
npx cap sync ios
npx cap open ios

Esto abre el proyecto en Xcode. Los pasos adicionales para iOS son:

  1. Seleccionar el Signing Team en la pestana Signing & Capabilities del target
  2. Configurar el Bundle Identifier (debe coincidir con el appId de Capacitor)
  3. Para distribucion, crear un provisioning profile en el Apple Developer Portal

Para generar un IPA para TestFlight o el App Store:

  1. Menu Product > Archive
  2. En el Organizer, seleccionar Distribute App
  3. Elegir App Store Connect o Ad Hoc segun el destino

La firma de codigo en iOS es significativamente mas compleja que en Android. Necesitas un Apple Developer Program ($99/anual), certificados de distribucion y provisioning profiles que vinculen tu app con dispositivos especificos (en desarrollo) o con el App Store (en produccion).

Live reload durante desarrollo

Uno de los mayores beneficios de este enfoque es poder desarrollar la UI en el navegador y solo probar en el dispositivo cuando trabajas con plugins nativos.

Para live reload en el dispositivo, Capacitor puede apuntar a tu servidor de desarrollo:

// capacitor.config.ts (solo para desarrollo)
const config: CapacitorConfig = {
  // ...
  server: {
    url: "http://192.168.1.50:3000", // IP local de tu maquina
    cleartext: true,
  },
};

Con esto, la app en el dispositivo carga la UI desde tu servidor de desarrollo y los cambios se reflejan al instante. Los plugins nativos siguen funcionando porque el bridge de Capacitor opera independientemente del origen del contenido web.

Recuerda revertir esta configuracion antes de hacer un build de produccion. Una buena practica es usar variables de entorno para alternar entre modos.

Problemas comunes y soluciones

Navegacion con rutas: Next.js con exportacion estatica genera archivos como about.html. Si usas <Link href="/about">, el WebView buscara /about sin extension. La solucion es configurar trailingSlash: true en next.config.ts, que genera about/index.html y la navegacion funciona correctamente.

CORS en peticiones API: Desde el WebView nativo, las peticiones van al servidor API sin el header Origin habitual. Configura tu API para aceptar peticiones desde el scheme de Capacitor (capacitor://localhost en iOS, https://localhost en Android).

Rendimiento del WebView: En dispositivos Android de gama baja, el WebView puede ser lento. Minimiza las animaciones CSS complejas, usa will-change con moderacion, y prefiere transform y opacity para transiciones que se ejecutan en la GPU.

Teclado virtual en Android: El teclado puede cubrir inputs. Agrega android:windowSoftInputMode="adjustResize" en el AndroidManifest.xml dentro de la etiqueta <activity> para que el WebView se redimensione automaticamente.

Conclusiones

Capacitor con Next.js no reemplaza a React Native o Flutter para aplicaciones con interfaces completamente nativas. Pero para aplicaciones donde la UI web es suficiente y necesitas acceso a APIs del dispositivo, es una solucion pragmatica que elimina la necesidad de mantener dos codebases. El mismo equipo frontend que construye la web puede entregar apps nativas, y el mismo codigo funciona en tres plataformas con adaptaciones minimas.