Stack technique
Monorepo pnpm + Turborepo : structurer une app web + mobile
Guide complet pour structurer un monorepo SaaS avec pnpm workspaces et Turborepo : packages partagés, types domain, CI optimisée, web TanStack + mobile Expo sous le même toit.
Tu veux partager du code entre ton app web et ton app mobile sans dupliquer. Monorepo. Mais quel outil ? Yarn workspaces, npm workspaces, Bun, pnpm + Turborepo, Nx ? Voici la structure exacte que j'utilise pour ApexKit, avec les raisons concrètes du choix.
Pourquoi pnpm + Turborepo en 2026
pnpm gère l'install et le linking des packages. Il est 2 à 3× plus rapide que npm grâce au store global, et il bloque les imports croisés non déclarés (strictness React Native indispensable).
Turborepo orchestre les tasks (build, lint, test, dev) avec cache intelligent et exécution parallèle. Sur un repo de 4-5 packages, ça divise les temps de CI par 3 ou 4.
Pourquoi pas Nx ? Nx est plus puissant mais surdimensionné pour un SaaS de 2-3 apps. La courbe d'apprentissage et la complexité de config ne valent pas le coup avant 10+ packages.
La structure cible
apexkit/
├── apps/
│ ├── web/ # TanStack Start (Vite + Nitro, React 19)
│ └── mobile/ # Expo Router (SDK 56)
├── packages/
│ ├── backend/ # Convex — source of truth (DB, auth, billing)
│ └── domain/ # Types TS purs partagés (Article, User, etc.)
├── tooling/
│ ├── setup/ # Script de bootstrap (renommer le projet)
│ └── release/ # Script de publication (vendor)
├── pnpm-workspace.yaml
├── turbo.json
└── package.jsonTrois niveaux : apps/* (apps déployables), packages/* (libs réutilisables), tooling/* (scripts dev). Convention simple, scalable jusqu'à 20+ packages.
Étape 1 — pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"C'est tout. pnpm détecte automatiquement les package.json dans ces dossiers et les linke en symlinks dans node_modules.
Étape 2 — Naming convention
Chaque package a un name scope cohérent. Pour ApexKit : @apexkit/backend, @apexkit/domain, @apexkit/web, @apexkit/mobile.
Dans apps/web/package.json, tu déclares la dépendance :
{
"name": "@apexkit/web",
"dependencies": {
"@apexkit/backend": "workspace:*",
"@apexkit/domain": "workspace:*"
}
}Le workspace:* dit à pnpm : utilise la version locale du workspace, pas le registre npm.
Étape 3 — turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".output/**", "dist/**", ".vercel/**"],
"env": ["VITE_*", "CONVEX_*", "NODE_ENV"]
},
"lint": {},
"test": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}Trois subtilités importantes :
dependsOn: ['^build']— build les packages dépendants avant de build l'app.outputs— déclare TOUS les dossiers générés. Manquer.vercel/outputcause des 404 en prod (Turbo ne ré-extrait pas du cache des artefacts non listés).env— liste les env vars qui invalident le cache. Si tu changesVITE_CONVEX_URL, Turbo rebuild.
Étape 4 — Package domain partagé
Le package @apexkit/domain contient uniquement des types TS purs — pas de runtime, pas de dépendances. Tu peux l'importer depuis web, mobile et backend sans risque d'embarquer du code lourd.
// packages/domain/src/article.ts
export type ArticleStatus = "draft" | "scheduled" | "published" | "archived";
export type Article = {
slug: string;
title: string;
status: ArticleStatus;
publishedAt?: number;
tags: string[];
};
// packages/domain/package.json
{
"name": "@apexkit/domain",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}Pas de build step. Le main pointe sur le .ts, et TypeScript résout. C'est ce qu'on appelle un internal package — pas de compilation, juste du source partagé.
Étape 5 — Web + mobile partagent un backend
Le package @apexkit/backend (Convex) expose son API générée. Web et mobile l'importent :
// apps/web/src/routes/index.tsx
import { api } from "@apexkit/backend/convex/_generated/api";
const articles = useQuery(api.articles.listPublished);
// apps/mobile/app/index.tsx
import { api } from "@apexkit/backend/convex/_generated/api";
const articles = useQuery(api.articles.listPublished);Même import, même typage, même backend. La différence : web utilise ConvexReactProvider, mobile utilise ConvexReactProvider + expoClient() pour les bearer tokens.
Étape 6 — Scripts à la racine
// package.json (racine)
{
"scripts": {
"dev": "turbo run dev",
"dev:web": "pnpm --filter @apexkit/web dev",
"dev:convex": "pnpm --filter @apexkit/backend convex:dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test"
}
}Depuis la racine : pnpm dev lance tout en parallèle, pnpm dev:web que le web, etc. Les --filter sont essentiels pour cibler un package précis.
CI optimisée
Turborepo brille en CI. Avec un cache distant (Turbo Cloud, gratuit jusqu'à un certain volume, ou GitHub Actions cache) :
# .github/workflows/ci.yml
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
with: { version: 9 }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Sur un repo touché en moyenne sur 2 packages/PR, 90 % des tasks sont cache hits. Une CI de 8 minutes tombe à 90 secondes.
Pièges classiques
Hoisting et React Native
Metro (le bundler RN) ne supporte pas tous les symlinks. Solution : .npmrc à la racine de apps/mobile avec node-linker=hoisted. pnpm utilise alors un node_modules plat pour ce package précis.
Types Convex non générés
Si le web ne reconnaît pas l'API Convex, c'est que convex/_generated/ n'existe pas. Solution : pnpm dev:convex une fois pour générer, puis Turbo s'en charge en CI via dependsOn.
ESLint config partagé
ESLint 9 (flat config) supporte les configs partagées via package : @apexkit/eslint-config. Crée un package, exporte un array de config, importe-le dans chaque app.
Quand passer à Nx ?
Quand tu as :
10+ packages dans le repo
Plusieurs équipes qui touchent au repo
Besoin de visualiser le graph de dépendances
Des plugins custom complexes
Avant ça, pnpm + Turborepo gagne sur la simplicité. Un junior peut comprendre le repo en 30 min.
La structure ApexKit en récap
ApexKit est exactement ce monorepo : web TanStack Start + mobile Expo + backend Convex + types partagés, scripts CI optimisés, déploiement VPS ou Vercel et EAS Build cablés. Plus de 200 heures de plomberie économisées — c'est l'argument principal du template.
Tu peux suivre ce tuto et tout construire toi-même (j'encourage), ou récupérer une base validée et te concentrer sur les features qui font ton produit.
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.