Stack technique
Partager un backend Convex entre TanStack (web) et Expo (mobile)
Architecture full-stack avec un seul backend Convex pour ton app web TanStack Start et ton app mobile Expo : auth, realtime, queries partagées, différences cookies vs bearer tokens.
Tu veux livrer ton SaaS sur le web et sur mobile sans coder un backend deux fois. Bonne nouvelle : avec Convex comme source of truth, TanStack Start pour le web et Expo Router pour le mobile, tu obtiens un setup full-stack cross-platform qui partage 80 % de la logique. Voici l'architecture concrète, avec les vraies différences à gérer.
L'architecture full-stack cross-platform
Une seule source de vérité (Convex), deux clients qui consomment les mêmes queries et mutations. Pas de duplication d'API, pas de schémas désynchronisés, pas d'auth à câbler deux fois.
┌──────────────────────────────────────────────────┐
│ @apexkit/backend (Convex) │
│ schema · queries · mutations · auth · billing │
└──────────────┬─────────────────────┬─────────────┘
│ │
┌───────▼────────┐ ┌────────▼──────────┐
│ apps/web │ │ apps/mobile │
│ TanStack │ │ Expo Router │
│ Start (SSR) │ │ (React Native) │
└────────────────┘ └───────────────────┘Les deux apps importent @apexkit/backend comme une dépendance workspace. Elles voient les mêmes types, appellent les mêmes APIs.
Le code partagé (80 % du travail)
Toute la logique métier vit dans packages/backend/convex/. Les deux clients consomment via api.module.function.
// packages/backend/convex/articles.ts
export const listPublished = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
return await ctx.db
.query("articles")
.withIndex("by_status", q => q.eq("status", "published"))
.order("desc")
.take(args.limit ?? 20);
},
});// apps/web/src/routes/blog/index.tsx — TanStack Start
import { useQuery } from "convex/react";
import { api } from "@apexkit/backend/convex/_generated/api";
export default function BlogIndex() {
const articles = useQuery(api.articles.listPublished, { limit: 20 });
return <ArticleGrid articles={articles ?? []} />;
}// apps/mobile/app/(tabs)/blog.tsx — Expo Router
import { useQuery } from "convex/react";
import { api } from "@apexkit/backend/convex/_generated/api";
export default function BlogScreen() {
const articles = useQuery(api.articles.listPublished, { limit: 20 });
return <ArticleList articles={articles ?? []} />;
}Strictement le même appel. Même type retourné. Et — magique — les deux clients reçoivent les updates realtime simultanément. Tu publies un article depuis le dashboard web, l'app mobile l'affiche sans refresh.
Les 20 % qui diffèrent : la couche transport
1. Auth : cookies (web) vs bearer (mobile)
Web : Better Auth stocke la session dans un cookie HTTP-only. ConvexReactProvider ramasse le cookie automatiquement.
Mobile : pas de cookies (mal supportés par React Native). On utilise des bearer tokens stockés dans expo-secure-store (Keychain iOS / Keystore Android), envoyés en header Authorization: Bearer <token>.
// apps/mobile/app/_layout.tsx
import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { authClient } from "@/lib/auth-client";
const convex = new ConvexReactClient(
process.env.EXPO_PUBLIC_CONVEX_URL!,
{ unsavedChangesWarning: false }
);
export default function RootLayout() {
return (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
<Slot />
</ConvexBetterAuthProvider>
);
}2. SSR (web only)
TanStack Start fait du SSR : le HTML initial inclut déjà les données fetched. Convex supporte ça via convex/nextjs-style integration ou via des loaders TanStack qui appellent l'API Convex HTTP.
Mobile : pas de SSR, juste du SPA classique. Le premier render affiche un skeleton, puis le data arrive.
3. Storage offline
Convex stream le data dès qu'on est online. Pour offline-first sur mobile, tu peux utiliser le hook useOfflineMutation (officiel Convex) qui queue les mutations en local et les rejoue à la reconnexion.
Web rarely needs ça — un SaaS doit être online pour faire ses queries de toute façon.
Configurer les deux apps
Web (apps/web)
// apps/web/src/router.tsx
import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL,
);
export function App({ children }) {
return (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
{children}
</ConvexBetterAuthProvider>
);
}Mobile (apps/mobile)
# Crée la nouvelle app Expo
pnpm create expo apps/mobile --template tabs
cd apps/mobile
# Ajoute Convex + Better Auth
pnpm add convex @convex-dev/better-auth @better-auth/expo expo-secure-storeVariables d'env Expo : préfixer EXPO_PUBLIC_ pour qu'elles soient embarquées dans le bundle.
Realtime cross-device : le moment magique
C'est le moment où Convex prouve sa supériorité sur un backend classique. Trois scénarios qu'on a tous vécus :
Un user édite un profil depuis l'app mobile. Le dashboard web affiché en parallèle se met à jour sans refresh.
Un admin publie un article depuis le web. La liste sur mobile s'incrémente automatiquement.
Un webhook Stripe upgrade un user. Le bouton « Premium » se déverrouille des deux côtés en même temps.
Zéro code de sync. Tu écris useQuery, Convex s'occupe du reste. C'est ce qu'on voulait de Firebase il y a 10 ans, version mature et typée.
Le typage cross-package
Le package @apexkit/domain contient les types métier purs (pas de runtime). Les deux apps et le backend l'importent :
// packages/domain/src/article.ts
export type ArticleStatus = "draft" | "published" | "archived";
// packages/backend/convex/articles.ts
import type { ArticleStatus } from "@apexkit/domain";
// apps/web et apps/mobile
import type { ArticleStatus } from "@apexkit/domain";Tu changes un type, TypeScript signale les 3 packages à mettre à jour avant compilation. Plus de drift entre backend et UI.
Pièges spécifiques mobile
Hermes et Intl
Le moteur Hermes (par défaut Expo) renvoie en-US pour Intl.NumberFormat — bug connu. Pour le format de devises, utilise expo-localization pour récupérer la vraie locale device, puis passe-la explicitement à toLocaleString.
Deep links OAuth
Pour que le callback Google OAuth revienne dans l'app mobile, tu déclares un scheme dans app.json (ex: "scheme": "apexkit"), puis Better Auth ouvre apexkit://callback. L'app est réveillée avec le token.
Metro et symlinks pnpm
Metro (bundler RN) gère mal les symlinks pnpm. Solution : .npmrc avec node-linker=hoisted dans apps/mobile/.
Le tradeoff à connaître
Un seul backend Convex pour web + mobile = un seul point de défaillance. Si Convex est down, les deux apps sont down. C'est le tradeoff classique du backend partagé.
Mitigation : Convex a un uptime > 99.9 %, et les queries cachées côté client survivent quelques secondes d'indispo. Pour un SaaS B2C ou B2B early-stage, c'est largement acceptable.
Récap : ce que tu obtiens
Un schéma DB unique, typé end-to-end
Queries/mutations partagées entre web et mobile
Auth unifiée (Better Auth) cookies + bearer
Realtime cross-device automatique
Billing Stripe centralisé
80 % du code métier réutilisé
Le ratio temps-investi / value-obtenue est imbattable pour un solo-founder qui veut livrer rapidement web + mobile.
ApexKit livre exactement cette stack
Si tu veux récupérer cette architecture déjà câblée — TanStack Start + Expo Router + Convex partagé + Better Auth web/mobile + Stripe + tests — c'est exactement le scope d'ApexKit. L'offre « Web + Mobile » te file les deux apps pré-configurées, prêtes à déployer sur VPS ou Vercel et à publier sur EAS Build.
Une journée d'install vs deux mois de plomberie. Tu choisis.
Construire ton SaaS encore plus vite
ApexKit est le template full-stack TypeScript que j'ai construit pour livrer un SaaS en une semaine : auth passwordless, multi-tenant, billing Stripe, blog, docs et dashboard admin — câblés ensemble avec TanStack Start et Convex.
Web seul ou Web + mobile (Expo), source-of-truth Convex partagée, prêt à déployer sur VPS ou Vercel en quelques minutes.