Gardez l’application web lorsqu’elle répond déjà au parcours mobile. Envisagez Capacitor si son interface reste adaptée et que vous avez besoin d’une distribution native ou d’intégrations précises. Choisissez React Native lorsque le produit justifie une interface distincte, conçue autour des interactions natives. Expo est un framework utilisable pour cette application React Native.
La question décisive est ce que les utilisateurs doivent accomplir sur leur téléphone, et quelles parties du SaaS existant restent pertinentes. Un tableau de bord consulté occasionnellement pour approuver une demande n’a pas les mêmes contraintes qu’une application utilisée toute la journée pour scanner, manipuler des listes et naviguer par gestes. Partez de ces parcours, puis évaluez la réutilisation du code, l’authentification et la maintenance.
Commencer par la situation du produit
Voici des recommandations d’architecture, conditionnées par les besoins :
| Situation | Première option à étudier | Preuve susceptible de changer le choix |
|---|---|---|
| Formulaires, approbations et tableaux de bord responsives ; capacités requises disponibles dans les navigateurs ciblés | Web/PWA | Une capacité indispensable ou un impératif de distribution dépasse ces navigateurs. |
| Interface web encore pertinente sur mobile ; quelques intégrations natives clairement identifiées | Capacitor | Le parcours le plus difficile exige beaucoup d’UI spécifique ou dépend d’un plugin inadapté. |
| Usage mobile fréquent, navigation complexe, gestes ou interactions natives au cœur du produit | React Native, avec Expo comme option de framework | Un prototype web représentatif satisfait déjà les exigences d’expérience et de maintenance. |
Une PWA constitue un véritable choix produit. L’installation et l’ouverture dans une fenêtre autonome existent sur les combinaisons navigateur/système compatibles, avec des interfaces d’installation différentes. Vérifiez les appareils de votre audience. L’installation ne réalise pas à elle seule les lectures hors ligne, les écritures différées ou la résolution des conflits. MDN décrit les conditions d’installation ; web.dev distingue la gestion des données hors ligne.
Comprendre les trois modèles d’exécution
Web/PWA exécute une interface web dans le navigateur, éventuellement dans une fenêtre installée. Le serveur Next.js déployé peut conserver l’authentification, le rendu à la requête et les opérations métier. Un service worker peut améliorer certains comportements réseau ; il ne déplace pas la base de données sur le téléphone.
Capacitor intègre une application web dans des projets natifs iOS/Android. L’interface conserve son rendu web ; les plugins relient JavaScript aux fonctionnalités natives. React DOM et CSS restent réutilisables lorsque l’expérience mobile s’y prête. Les composants d’Ionic sont facultatifs : Capacitor n’impose pas une interface Ionic. Voir les choix d’interface et l’intégration des plugins.
React Native utilise des composants comme View, Text et TextInput
associés aux vues des plateformes. Connaître React aide pour la composition et
l’état ; changer un import ne transforme pas un composant DOM en composant
natif. React Native utilise aujourd’hui la New Architecture : l’ancien bridge
sérialisé ne décrit plus correctement son fonctionnement par défaut.
Voir les composants fondamentaux
et la transition d’architecture.
Expo fournit un framework, des bibliothèques et des outils autour de React Native. Ses services hébergés facultatifs relèvent d’un autre choix opérationnel. Les builds locaux restent possibles avec les outils Android/iOS appropriés. Expo Go est un environnement générique ; un development build intègre la configuration et les bibliothèques natives propres à votre app. Aucun des deux termes ne signifie une version de production. La FAQ Expo et le guide des development builds détaillent ces limites.
Partager des contrats, sans inventer un pourcentage
Inventoriez les modules avant d’estimer leur réutilisation. Repérez les
dépendances à window, au DOM, aux CSS, aux API Node et au stockage de chaque
plateforme. Une courte liste de dépendances peut être plus déterminante que le
nombre de lignes d’un fichier.
| Couche | Web/PWA et Capacitor | React Native |
|---|---|---|
| React DOM, HTML, CSS et composants Radix basés sur le DOM | Réutilisables lorsque la présentation et les interactions mobiles restent adaptées | Refaire la présentation avec des composants natifs ou une bibliothèque explicitement compatible. |
| Design tokens | Réutiliser les valeurs sémantiques et les styles web | Partager les données pertinentes ; adapter variables CSS, unités et typographie aux styles natifs. |
| DTO TypeScript et validation | Partager les modules compatibles avec le navigateur | Partager ceux dont les dépendances et la sérialisation fonctionnent dans l’outillage natif. |
| Client API | Partager protocole, décodage et modèle d’erreur | Partager ce noyau ; adapter transport, annulation et credentials. |
| Transformations pures et logique d’état React | Souvent réutilisables | Réutilisables si elles ne dépendent ni du DOM ni d’API spécifiques. |
| Base de données, autorisation et transactions métier | Conserver côté serveur | Appeler les mêmes opérations autorisées, sans embarquer le serveur. |
Un type partagé ne valide pas une réponse réseau. Une assertion comme
payload as Project[] disparaît à la compilation. Décodez le JSON non fiable
à l’entrée du client ; laissez le serveur contrôler l’appartenance au workspace
et les permissions. La documentation TypeScript
explique cette distinction.
Définir ce qui reste sur le serveur Next.js
Une application Next.js fonctionnelle sur le web n’est pas automatiquement exportable en fichiers statiques embarqués. Commencez par classer ses routes.
Avec output: 'export', Next.js produit des fichiers distribuables par un
hébergement statique ou un répertoire d’assets Capacitor. Certains Server
Components peuvent s’exécuter pendant ce build. La question est de savoir si
leur résultat peut être calculé à cet instant, sans comportement serveur propre
à chaque requête. Le guide d’export statique
décrit ce modèle.
| Comportement existant | Conséquence pour un frontend statique embarqué |
|---|---|
| Server Component produisant un contenu public au build | Compatible lorsque ses dépendances permettent l’export statique. |
| Cookies de session lus côté serveur à chaque requête | Conserver cette décision sur un serveur distant ; un fichier exporté n’a pas de contexte propre à l’utilisateur. |
| Server Action modifiant la base de données | Les Server Actions ne sont pas prises en charge par l’export statique. Conserver la mutation sur un serveur distant avec une intégration client/API explicite. |
| Route dynamique dont les paramètres sont connus au build | Générable dans les contraintes de l’export ; les chemins futurs inconnus nécessitent une autre conception du routage et des données. |
| Handler API dépendant de la requête | L’exécuter à distance. Une réponse GET statique produite au build n’est pas une API privée et dynamique. |
Ces contraintes figurent dans les limites de l’export Next.js et son guide backend-for-frontend. Ne placez jamais des données privées d’utilisateurs dans un artefact public pour obtenir un export réussi.
Trois organisations cohérentes en découlent : améliorer le web/PWA en conservant le déploiement Next.js ; extraire un frontend réellement exportable pour Capacitor avec des opérations serveur distantes ; ou créer un client React Native utilisant une API explicite. Cette API peut rester dans le backend existant. Choisir un rendu mobile ne justifie pas, à lui seul, un nouveau service.
Dans Capacitor, webDir désigne les assets web compilés. Le réglage
server.url est documenté pour le live reload et n’est pas destiné à la
production. Le faire pointer vers le SaaS hébergé ne constitue donc pas une
stratégie de distribution validée. Vérifiez le
contrat de configuration Capacitor.
Un petit client commun, deux présentations
Exemple original et HTTP simulé
Cet exemple original liste des projets fictifs. Il illustre une frontière entre
modules ; ce n’est ni une application mobile complète ni l’API Starter Pro.
Son endpoint d’exemple est GET /api/projects et la vérification utilise des
réponses HTTP simulées.
Les deux plateformes partagent un décodeur JSON et un client. Leurs adaptateurs de chargement définissent le contexte de requête ; les vues reçoivent les mêmes données validées. Aucun serveur, package privé ou credential n’est fourni.
Contrat et client
export type Project = Readonly<{ id: string; name: string }>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseProjects(value: unknown): readonly Project[] {
if (!isRecord(value) || !Array.isArray(value.items)) {
throw new Error("Invalid projects response");
}
const ids = new Set<string>();
return value.items.map((item: unknown) => {
if (
!isRecord(item) ||
typeof item.id !== "string" ||
item.id.length === 0 ||
ids.has(item.id) ||
typeof item.name !== "string" ||
item.name.trim().length === 0
) {
throw new Error("Invalid project");
}
ids.add(item.id);
return { id: item.id, name: item.name };
});
}import { parseProjects, type Project } from "./projects";
type Reply = { status: number; json(): Promise<unknown> };
export type ProjectTransport = (signal: AbortSignal) => Promise<Reply>;
type Failure =
"aborted" | "network" | "unauthorized" | "http" | "invalid-response";
export class ProjectRequestError extends Error {
constructor(readonly kind: Failure) {
super(kind);
this.name = "ProjectRequestError";
}
}
export async function listProjects(
get: ProjectTransport,
signal: AbortSignal,
): Promise<readonly Project[]> {
if (signal.aborted) throw new ProjectRequestError("aborted");
let reply: Reply;
try {
reply = await get(signal);
} catch {
throw new ProjectRequestError(signal.aborted ? "aborted" : "network");
}
if (signal.aborted) throw new ProjectRequestError("aborted");
if (reply.status === 401) throw new ProjectRequestError("unauthorized");
if (reply.status !== 200) throw new ProjectRequestError("http");
try {
const body: unknown = await reply.json();
if (signal.aborted) throw new ProjectRequestError("aborted");
return parseProjects(body);
} catch {
throw new ProjectRequestError(
signal.aborted ? "aborted" : "invalid-response",
);
}
}Adaptateurs de chargement distincts
Gestion des identifiants
L’adaptateur navigateur demande les cookies de même origine. L’adaptateur natif
n’ajoute aucun header Authorization et n’implémente aucune connexion. React
Native documente des limites de cookies Fetch, dont credentials: "omit" :
l’exemple ne garantit donc pas l’isolation des cookies. Un backend protégé doit
refuser toute requête sans credentials valides. Compiler ce code ne prouve pas
une session native authentifiée.
L’URL web relative vise le navigateur de même origine ; un frontend Capacitor
embarqué aurait besoin d’un adaptateur distant et d’une politique de credentials
et d’origines vérifiés, non implémentés ici.
import { listProjects } from "../shared/client";
export function loadWebProjects(signal: AbortSignal) {
return listProjects(
(requestSignal) =>
fetch("/api/projects", {
method: "GET",
headers: { Accept: "application/json" },
credentials: "same-origin",
cache: "no-store",
redirect: "error",
signal: requestSignal,
}),
signal,
);
}import { listProjects } from "../shared/client";
// Fictitious endpoint; not deployed. This adapter does not implement native login.
export function loadNativeProjects(signal: AbortSignal) {
return listProjects(
(requestSignal) =>
fetch("https://api.example.test/api/projects", {
method: "GET",
headers: { Accept: "application/json" },
signal: requestSignal,
}),
signal,
);
}Composants de rendu distincts
Passez le résultat de loadWebProjects ou loadNativeProjects au composant
Projects correspondant. L’écran appelant doit encore gérer chargement,
erreurs, annulation et changements de compte. Ces vues pures illustrent une
liste et son état vide ; elles n’implémentent pas le cycle de vie d’un écran.
import type { Project } from "../shared/projects";
export function Projects({ projects }: { projects: readonly Project[] }) {
return (
<section aria-labelledby="projects-heading">
<h1 id="projects-heading">Projects</h1>
{projects.length === 0 ? (
<p>No projects yet.</p>
) : (
<ul>
{projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
)}
</section>
);
}import { FlatList, Text, View } from "react-native";
import type { Project } from "../shared/projects";
export function Projects({ projects }: { projects: readonly Project[] }) {
return (
<View>
<Text accessibilityRole="header">Projects</Text>
<FlatList
data={projects}
keyExtractor={(project) => project.id}
renderItem={({ item }) => <Text>{item.name}</Text>}
ListEmptyComponent={<Text>No projects yet.</Text>}
/>
</View>
);
}Le partage s’arrête avant la couche de rendu. Une liste HTML et une FlatList
native présentent les mêmes données avec leurs propres sémantique, mise en
page et comportement d’accessibilité. Une abstraction commune supplémentaire
se justifie lorsqu’elle simplifie la maintenance d’une vraie seconde
implémentation.
Vérification et reproduction
Les six blocs ont été extraits des deux articles et contrôlés dans une fixture
isolée : Node 24.22.1, pnpm 10.32.1, TypeScript 6.0.3, React et
@types/react 19.3.0, React Native 0.88.1.
| Contrôle | Ce que le résultat établit |
|---|---|
| TypeScript web, deux articles | Vérification stricte des sources et déclarations avec les bibliothèques DOM. |
| TypeScript natif, deux articles | Vérification stricte des sources avec les types React Native et skipLibCheck: true, conformément au profil standard. |
| 18 tests Node, neuf par langue | JSON, erreurs HTTP, réseau, annulation et options demandées simulés. Aucun serveur HTTP réel ni stockage natif de cookies n’est exercé. |
Limites de la vérification
Le contrôle intégral des déclarations a révélé trois erreurs dans les packages React Native installés ; le résultat natif ne certifie pas ces déclarations tierces. Les vues TSX ont été vérifiées par le compilateur, pas exécutées sur appareil.
Pour reproduire la compilation, utilisez Node 24 et pnpm 10.32.1 dans un dossier vide hors du workspace applicatif. Enregistrez les six blocs aux chemins indiqués, puis exécutez :
pnpm init
pnpm add --save-dev --save-exact --ignore-scripts \
typescript@6.0.3 @types/react@19.3.0 react@19.3.0 react-native@0.88.1
pnpm exec tsc --noEmit --strict --noUncheckedIndexedAccess \
--exactOptionalPropertyTypes --verbatimModuleSyntax --target ES2022 \
--module ESNext --moduleResolution Bundler --jsx react-jsx \
--lib ES2022,DOM,DOM.Iterable --types react \
web/Projects.tsx web/load-projects.ts
pnpm exec tsc --noEmit --strict --noUncheckedIndexedAccess \
--exactOptionalPropertyTypes --verbatimModuleSyntax --target ES2022 \
--module ESNext --moduleResolution Bundler --jsx react-native \
--lib ES2022 --types react-native --customConditions react-native \
--skipLibCheck true native/Projects.tsx native/load-projects.tsConcevoir l’authentification séparément
Une session par cookie dans le navigateur ne fournit pas automatiquement un protocole de connexion natif. Les origines et politiques de cookies doivent aussi être vérifiées dans la WebView Capacitor. Auth.js décrit des stratégies de session web ; cela n’établit pas un émetteur OAuth pour un nouveau client mobile.
Pour OAuth en natif, utilisez le navigateur système et le mécanisme de retour
pris en charge par le fournisseur. Pour un client public utilisant le flux
Authorization Code, utilisez PKCE. Validez les URI de retour enregistrées et
les protections de liaison à la transaction, notamment state ou le nonce
OIDC selon le flux. Ne reconstituez pas un protocole partiel. Un secret devant
rester confidentiel appartient au serveur, jamais au bundle mobile.
Références : RFC 8252,
OAuth Security BCP
et guide d’authentification Expo.
Décrivez le cycle de vie complet des credentials avant de retenir une solution :
- Émission et validation : système émetteur, API destinataire et contrôle serveur de l’audience et des autorisations.
- Expiration et renouvellement : durée de vie, conditions de renouvellement et rotation lorsque pertinente.
- Stockage : mécanisme de plateforme approprié, comme SecureStore pour les credentials adaptés ; aucun secret dans AsyncStorage, les sources ou les logs.
- Révocation : comportement serveur après déconnexion, suppression de compte, retrait d’accès ou perte d’appareil ; sort des requêtes déjà admises.
- Changement de compte : annulation des opérations et nettoyage des caches liés à l’identité avant d’afficher les données du compte suivant.
SecureStore protège le stockage local ; il ne révoque pas les credentials sur le serveur. Effacer un jeton local ne prouve pas une déconnexion serveur. Testez les retours OAuth dans un development build adapté ; Expo Go ne valide pas ces retours personnalisés.
Si ce cycle reste indéterminé, conservez la connexion web et différez la promesse d’authentification native. Une démonstration de contrat/client reste utile, à condition d’en décrire exactement la portée.
Prototyper l’UX, les performances et la maintenance
Choisissez le parcours réaliste le plus difficile. Incluez clavier au-dessus d’un formulaire, zones sûres, liens profonds, retour Android, gestes, longue liste, refus de permission et retour depuis l’arrière-plan. Vérifiez l’agrandissement du texte, le focus et les libellés accessibles. Un plugin disponible demande encore une intégration : plateformes prises en charge, maintenance, permissions et comportements d’échec.
Avec Capacitor, examinez le rendu web, le travail sur le thread principal, le défilement et les interactions avec les plugins. Avec React Native, examinez le rendu des composants, la virtualisation des listes, le travail JavaScript et les intégrations natives. Les deux approches peuvent offrir une mauvaise expérience si l’application fait trop de travail ou gère mal son cycle de vie.
Mesurez le même parcours et le même jeu de données sur des appareils représentatifs, dans des conditions réseau comparables et avec des builds de production. Observez démarrage, latence d’interaction, mémoire, listes et reprise après interruption. Le guide de performance React Native distingue le surcoût du développement du comportement en production. Cet article ne présente aucun benchmark sur appareil ni classement mesuré.
La maintenance revient à chaque évolution : UI des clients, QA iOS/Android, SDK, plugins, signature, soumissions et support des anciennes versions installées. Une réécriture d’UI limitée peut demander beaucoup d’intégration ; une UI native distincte peut partager contrats et règles serveur. Aucun pourcentage universel de réutilisation ni multiplicateur de coût n’explique ces produits différents.
Prévoir distribution et mises à jour
Capacitor et React Native exigent des builds natifs adaptés, la signature, des essais d’installation et un processus de distribution. Les services de build hébergés d’Expo restent facultatifs ; les builds locaux conservent les prérequis des outils de chaque plateforme.
Une mise à jour doit être compatible avec le runtime natif déjà installé. Ajouter du code natif nécessite un nouveau binaire compatible ; un mécanisme de mise à jour JavaScript ne supprime pas cette frontière. Les runtime versions Expo documentent ce problème.
Le framework ne garantit ni l’acceptation dans les stores ni une exception aux règles de paiement. Vérifiez produit, territoires de distribution et parcours d’achat dans les règles Apple, les exigences de fonctionnalité Google Play et sa politique de paiement. La présence d’une WebView ne suffit pas à trancher ces questions.
Trois décisions d’architecture concrètes
Un back-office B2B avec des approbations occasionnelles
Supposons que le produit possède déjà de bons formulaires responsives, des détails de tableaux et une gestion des comptes. Les utilisateurs travaillent surtout sur ordinateur et approuvent parfois une demande sur téléphone. Aucune capacité indispensable n’a été identifiée comme inaccessible aux navigateurs ciblés.
Commencez par le web/PWA. Améliorez les cibles tactiles, la récupération des formulaires, la navigation et le comportement lors d’une coupure réseau. Envisagez Capacitor lorsqu’un besoin précis de distribution ou d’intégration native apparaît. Changez de décision si ce besoin ne peut pas être satisfait dans le navigateur ; ajouter une icône ne justifie pas deux feuilles de route UI.
Un produit mobile fondé sur des interactions fréquentes
Supposons que le parcours central combine scans répétés, gestes, capture média et longues listes interactives. La navigation mobile est essentielle et l’équipe peut maintenir une présentation distincte.
Étudiez React Native avec Expo et validez le parcours matériel le plus difficile dans un development build. Le compromis porte sur l’UI native et sa maintenance, même si contrats de données et règles serveur restent communs. Reconsidérez ce choix si un prototype web/Capacitor satisfait les mêmes exigences avec une meilleure maintenabilité pour cette équipe. La décision repose sur les preuves du parcours, pas sur le nom du framework.
Un SaaS Next.js qui ajoute un client complémentaire
Supposons que le web s’appuie sur des Server Actions, des sessions privées et une autorisation des workspaces côté serveur. Le client mobile ne nécessite qu’une partie des parcours.
Exposez d’abord une petite API versionnée autour de ces opérations, en gardant permissions et transactions sous contrôle serveur. Choisissez ensuite Capacitor ou React Native pour les écrans concernés. Ne faites pas appeler son propre serveur en HTTP à chaque composant web uniquement pour partager une abstraction client. Le web peut conserver des appels directs aux fonctions serveur pendant que le mobile utilise l’API.
Modifiez le plan si l’authentification native ou une capacité indispensable ne peut pas être validée dans le périmètre prévu. La fondation web utile peut progresser sans annoncer prématurément un produit natif.
Valider un parcours complet avant de s’engager
Utilisez cette checklist sur un parcours utilisateur représentatif :
- Définir l’usage mobile et la condition qui justifierait de quitter le web.
- Classer les routes Next.js selon leur exécution : build, client ou serveur à chaque requête.
- Compiler les modules partagés dans les deux outillages et inspecter les bundles ; exclure modules serveur et secrets.
- Exécuter connexion, retours d’app, expiration, renouvellement pertinent, révocation et changement de compte dans le runtime visé.
- Tester clavier, retour, zones sûres, texte agrandi, accessibilité et permissions matérielles sur les appareils ciblés.
- Définir séparément lectures et écritures hors ligne, caches privés et conflits ; vérifier la reconnexion.
- Mesurer les builds de production selon une cible d’expérience explicite et consigner appareils, données et conditions réseau.
- Nommer le responsable des mises à jour SDK/plugins, de la signature, des obligations stores et des anciens clients.
- Consigner la décision, les risques ouverts et la preuve qui ferait changer d’avis.
Versions étudiées et limites des preuves
Les documentations officielles ont été vérifiées le 8 novembre 2026 :
| Technologie étudiée | Documentation ou version de référence |
|---|---|
| Capacitor 8.6.0, documentation stable v8 | Version Capacitor |
| React Native 0.88 | Version React Native |
| Expo SDK 58, React Native 0.88 et React 19.3.0 | Référence Expo SDK |
| Guide Next.js App Router affichant 16.4.5 | Export statique |
Ce sont les versions étudiées, pas la liste de quatre frameworks installés et testés. La vérification de l’exemple précise son propre outillage. Aucun build Capacitor, bundle Expo/Metro, simulateur ou appareil iOS/Android, connexion native ou dépôt en store n’a été validé pour cet article. Les recommandations des scénarios sont des jugements d’architecture ; les sources documentent les capacités et les contraintes sur lesquelles ils reposent.
Pour poursuivre, utilisez la checklist des fondations SaaS afin d’identifier les responsabilités d’authentification, de données et d’exploitation nécessaires à votre client. Une frontière serveur explicite reste utile quel que soit le rendu retenu.