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.
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.
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.
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.
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" />
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}`;
}
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();
}, []);
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"
/>
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:
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
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:
appId de Capacitor)Para generar un IPA para TestFlight o el App Store:
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).
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.
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.
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.