MyRoadTrip — Une app web et iOS/Android avec le même code, sans React Native
Deux décisions ont fait que MyRoadTrip tourne sur web, App Store et Play Store depuis le même repo Vue 3 — sans Flutter, sans React Native, sans Expo. Voici comment Capacitor + une discipline sur les APIs natives m'a évité 6 mois de double-codage.
Cet article décortique le projetMyRoadTripLe moment de vérité, mois 2
J'ai lancé MyRoadTrip en mars 2025 comme app web. Quasar 2 + Vue 3 + Pinia + Mapbox GL. PWA installable, offline-first via CRDT, sync entre appareils via Firestore.
Mois 1 : 12 premiers utilisateurs. Mois 2 : 47 utilisateurs.
Mois 2,5 : 4 retours, mot pour mot identiques :
"C'est super, mais c'est quand l'app dans l'App Store ?"
Les utilisateurs voulaient une vraie app. Pas une PWA dans Safari avec "ajouter à l'écran d'accueil" caché dans 4 sous-menus. Une icône native. Une notification push qui marche. Un démarrage à froid en 0,4 s.
J'avais 4 options :
- Tout réécrire en React Native — 6 mois de boulot.
- Tout réécrire en Flutter — 6 mois + apprendre Dart.
- Maintenir 2 codebases en parallèle (web + native) — l'enfer.
- Capacitor — wrapper natif autour de mon app web existante.
Option 4 : 9 jours plus tard, l'app était sur TestFlight.
Capacitor en 30 secondes
Capacitor (par Ionic) est un runtime natif minimaliste. Il prend
exactement le même code web que ce que sert le navigateur, et le fait
tourner dans une WKWebView (iOS) ou WebView (Android), enveloppé
dans une vraie app native.
Le binaire iOS final fait 12 Mo. L'app fait toujours du Vue 3. Mais elle a un icône, un splash screen, des notifications push, du biométrique, et tout ce qu'on attend d'une vraie app.
pnpm add @capacitor/core @capacitor/ios @capacitor/android
npx cap init "MyRoadTrip" "fr.myroadtrip"
npx cap add ios
npx cap add android
C'est littéralement les 4 commandes pour avoir des projets Xcode + Android Studio.
La vraie difficulté : APIs natives
Le problème, c'est pas Capacitor. C'est ce qui se passe quand mon code doit appeler une API natif — la caméra, le GPS, les push notifications, le biométrique, le file system.
Sur web : navigator.geolocation.getCurrentPosition().
Sur iOS : CLLocationManager qu'il faut configurer dans Info.plist.
Sur Android : LocationManager qu'il faut demander en runtime depuis API 23.
Trois implémentations, trois codebases — défaite du concept de "même code".
La solution dans MyRoadTrip : abstraction stricte. Tout code natif passe par mes propres interfaces, jamais par les APIs Capacitor directement.
// shared/native/geolocation.ts
export interface GeolocationService {
getCurrent(): Promise<{ lat: number; lng: number; accuracy: number }>;
watchPosition(cb: (pos: Position) => void): Promise<string>;
clearWatch(id: string): Promise<void>;
}
// shared/native/geolocation.web.ts
export const geolocationWeb: GeolocationService = {
async getCurrent() {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
p => resolve({ lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy }),
reject,
{ enableHighAccuracy: true, timeout: 10000 }
);
});
},
// …
};
// shared/native/geolocation.native.ts
import { Geolocation } from '@capacitor/geolocation';
export const geolocationNative: GeolocationService = {
async getCurrent() {
const p = await Geolocation.getCurrentPosition({ enableHighAccuracy: true });
return { lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy };
},
// …
};
// shared/native/index.ts
import { Capacitor } from '@capacitor/core';
import { geolocationWeb } from './geolocation.web';
import { geolocationNative } from './geolocation.native';
export const geolocation: GeolocationService =
Capacitor.isNativePlatform() ? geolocationNative : geolocationWeb;
Mon code applicatif n'importe jamais @capacitor/geolocation ou
navigator.geolocation directement. Il importe geolocation du module abstrait.
À l'usage : await geolocation.getCurrent(). Web, iOS, Android, peu importe.
Le pattern qui m'a sauvé : un seul package.json
L'autre piège classique : avoir 2 package.json (web + mobile) avec des
dépendances qui divergent. Et puis un jour, ta lib préférée d'une version
décale et l'enfer commence.
Dans MyRoadTrip : un seul package.json. Capacitor ajoute ses
dépendances natives (@capacitor/ios, @capacitor/android, @capacitor/geolocation)
au même fichier que Vue et Quasar.
Quand je pnpm install, j'ai mon app web fonctionnelle. Quand je npx cap sync,
Capacitor copie mon dist/ dans les projets iOS + Android et ajoute les
plugins natifs requis. Tout vient de la même source.
Si je veux mettre à jour Vue 3.4 → 3.5, je le fais une fois. L'app web ET les
apps natives en bénéficient au prochain cap sync.
Ce qui m'a coûté du temps
Capacitor n'a pas tout réglé. Voici les 3 frictions que j'ai dû résoudre :
1. Le bridge async iOS et le AppDelegate
iOS ne charge pas les push notifications comme Android. Sur iOS, il faut
configurer le UNUserNotificationCenter dans AppDelegate.swift, et il
faut faire deux modifs Capacitor side pour que mes events JS soient correctement
routés.
Coût : 1 journée pour comprendre. Aujourd'hui c'est 8 lignes de Swift.
2. Le splash screen Android < API 31
Sur Android < 12, il faut une image splash spécifique format 9-patch,
sinon le splash est étiré horriblement. Sur iOS, il faut un launch screen
dans le storyboard, pas une image PNG.
Coût : 4 heures pour faire les 16 tailles d'icônes + splash. Aujourd'hui
j'utilise capacitor-assets qui le fait à partir d'un seul PNG 1024×1024.
3. Les permissions runtime Android
Sur Android API ≥ 23, chaque permission (location, camera, storage, notifications)
doit être demandée au moment de l'utiliser, pas au démarrage. Mon abstraction
geolocation.native.ts gère ça automatiquement, mais j'ai dû la coder.
Coût : 2 jours pour gérer proprement les 4 permissions principales.
Les résultats
Une codebase Vue 3 + Quasar → publiée sur :
- ✅ web (
myroadtrip.app) - 🟡 App Store (build TestFlight validé, soumission App Store en cours)
- 🟡 Play Store (build interne validé, soumission ouverte aux beta testers)
Latency démarrage à froid :
- Web mobile (Safari) : 1,8 s
- App native iOS : 0,4 s
- App native Android : 0,5 s
Taille du binaire :
- iOS .ipa : 14 Mo
- Android .apk : 22 Mo
Code dupliqué entre web et native : ~250 lignes dans shared/native/*.native.ts
(les wrappers d'APIs natives). Sur 48 000 lignes au total. 0,5 % de duplication.
À comparer avec React Native ou Flutter, où 100 % du code UI est à réécrire en JSX RN ou Dart Flutter.
La leçon
Le débat "React Native vs Flutter" me semble être un faux débat depuis que j'ai goûté à Capacitor. La vraie question est :
Mon app a-t-elle besoin de performances natives (3D, animations 60 FPS sur 1000 éléments, lecture vidéo 4K) ? Ou est-ce une app de productivité avec du formulaire, des listes, du mapping ?
Si tu as besoin de performance native pure : React Native ou Swift/Kotlin.
Si tu fais une app de productivité — comme 80 % des apps qu'on rencontre en SaaS B2B — Capacitor est le bon choix. C'est rapide, c'est stable, c'est maintenable, et ça te fait sortir 3 plateformes pour le prix d'une.
Le piège est plutôt d'avoir le réflexe "mobile = React Native" sans poser la question.
Stack & code
- Vue 3.5 + Quasar 2.18 + Pinia 2.3
- Capacitor 6 + plugins (geolocation, push-notifications, haptics, browser, app)
- shared/native/ = 12 fichiers
.web.ts/.native.ts(1 par API native) - Mapbox GL — fonctionne pareil sur web et natif
- Firebase pour la sync cloud
- 351 fichiers de tests (Vitest + Playwright) — la majorité passe sur les 3 plateformes
Le repo entier fait 48 000 lignes. Une seule personne peut le maintenir. C'est cette propriété, pas la beauté du code, qui définit un produit maintenable en solo.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience