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.

KIKillian
··6 min read
Circuit imprimé symbolisant l'architecture modulaire d'un monorepo logiciel

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.json

Trois 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/output cause 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 changes VITE_CONVEX_URL, Turbo rebuild.

Tableau Kanban avec post-its représentant les tâches parallèles d'un monorepo

É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.

Découvrir ApexKit →

Web seul ou Web + mobile (Expo), source-of-truth Convex partagée, prêt à déployer sur VPS ou Vercel en quelques minutes.

Testimonials

Command palette

Search and run actions.