Architecture SaaS

Capacitor vs React Native : quel choix pour un SaaS Next.js ?

PWA, Capacitor ou React Native avec Expo : comparez le rendu, les limites de Next.js, le code partageable et l’authentification avec un exemple TypeScript original.

Patrice Parny

Fondateur de PyColors

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 :

SituationPremière option à étudierPreuve susceptible de changer le choix
Formulaires, approbations et tableaux de bord responsives ; capacités requises disponibles dans les navigateurs ciblésWeb/PWAUne capacité indispensable ou un impératif de distribution dépasse ces navigateurs.
Interface web encore pertinente sur mobile ; quelques intégrations natives clairement identifiéesCapacitorLe 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 produitReact Native, avec Expo comme option de frameworkUn 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.

CoucheWeb/PWA et CapacitorReact Native
React DOM, HTML, CSS et composants Radix basés sur le DOMRéutilisables lorsque la présentation et les interactions mobiles restent adaptéesRefaire la présentation avec des composants natifs ou une bibliothèque explicitement compatible.
Design tokensRéutiliser les valeurs sémantiques et les styles webPartager les données pertinentes ; adapter variables CSS, unités et typographie aux styles natifs.
DTO TypeScript et validationPartager les modules compatibles avec le navigateurPartager ceux dont les dépendances et la sérialisation fonctionnent dans l’outillage natif.
Client APIPartager protocole, décodage et modèle d’erreurPartager ce noyau ; adapter transport, annulation et credentials.
Transformations pures et logique d’état ReactSouvent réutilisablesRéutilisables si elles ne dépendent ni du DOM ni d’API spécifiques.
Base de données, autorisation et transactions métierConserver côté serveurAppeler 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 existantConséquence pour un frontend statique embarqué
Server Component produisant un contenu public au buildCompatible lorsque ses dépendances permettent l’export statique.
Cookies de session lus côté serveur à chaque requêteConserver 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éesLes 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 buildGé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êteL’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

shared/projects.ts
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 };
  });
}
shared/client.ts
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.

web/load-projects.ts
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,
  );
}
native/load-projects.ts
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.

web/Projects.tsx
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>
  );
}
native/Projects.tsx
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ôleCe que le résultat établit
TypeScript web, deux articlesVérification stricte des sources et déclarations avec les bibliothèques DOM.
TypeScript natif, deux articlesVérification stricte des sources avec les types React Native et skipLibCheck: true, conformément au profil standard.
18 tests Node, neuf par langueJSON, 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 :

typecheck.sh
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.ts

Concevoir 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 :

  1. Émission et validation : système émetteur, API destinataire et contrôle serveur de l’audience et des autorisations.
  2. Expiration et renouvellement : durée de vie, conditions de renouvellement et rotation lorsque pertinente.
  3. Stockage : mécanisme de plateforme approprié, comme SecureStore pour les credentials adaptés ; aucun secret dans AsyncStorage, les sources ou les logs.
  4. 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.
  5. 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éeDocumentation ou version de référence
Capacitor 8.6.0, documentation stable v8Version Capacitor
React Native 0.88Version React Native
Expo SDK 58, React Native 0.88 et React 19.3.0Référence Expo SDK
Guide Next.js App Router affichant 16.4.5Export 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.