KlarkLabs
Retour au blog
Architecture
10 décembre 2025
6 min de lecture

Architecture SaaS avec Next.js

Comment structurer une application SaaS B2B scalable avec Next.js 15, du routing à la gestion multi-tenant.

Next.jsSaaSArchitecture

Construire un SaaS B2B scalable avec Next.js 15 demande bien plus qu'un simple projet create-next-app. L'architecture multi-tenant, la gestion des plans, l'isolation des données et les performances en production requièrent des décisions structurelles prises dès le départ. Voici l'architecture que nous utilisons en production.

Structure du projet

apps/
  web/                    # Application Next.js principale
    app/
      (marketing)/        # Pages publiques (landing, pricing)
      (auth)/             # Login, signup, reset password
      (dashboard)/        # Zone authentifiée
        [orgSlug]/        # Multi-tenant via segment dynamique
          settings/
          billing/
          [module]/
      api/                # Route handlers
    middleware.ts         # Auth + routing tenant
    components/
    lib/
      db/                 # Couche d'accès aux données
      auth/               # Configuration Auth.js
      billing/            # Intégration Stripe

Multi-tenancy via URL slug

Nous optons pour le modèle slug-based (app.example.com/acme/dashboard) plutôt que subdomain-based, plus simple à déployer et compatible avec tous les environnements.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Extraire l'orgSlug depuis l'URL
  const orgSlugMatch = pathname.match(/^\/([^\/]+)\/(dashboard|settings|billing)/);
 
  if (!orgSlugMatch) return NextResponse.next();
 
  const orgSlug = orgSlugMatch[1];
  const session = await getSession(request);
 
  if (!session) {
    return NextResponse.redirect(new URL(`/login?next=${pathname}`, request.url));
  }
 
  // Vérifier que l'utilisateur appartient bien à cette organisation
  const hasAccess = session.user.organizations.some(
    (org) => org.slug === orgSlug
  );
 
  if (!hasAccess) {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // Injecter l'orgSlug dans les headers pour les Server Components
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-org-slug", orgSlug);
 
  return NextResponse.next({ request: { headers: requestHeaders } });
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|api/webhook).*)"],
};

Isolation des données par organisation

L'isolation des données est critique pour un SaaS B2B. Nous utilisons une approche shared database, row-level isolation avec MongoDB, en encapsulant toutes les requêtes dans un helper qui force le filtre par organizationId.

// lib/db/tenant-context.ts
import { headers } from "next/headers";
import { db } from "./mongodb";
 
export async function getTenantDb() {
  const headersList = await headers();
  const orgSlug = headersList.get("x-org-slug");
 
  if (!orgSlug) throw new Error("No organization context");
 
  const org = await db.collection("organizations").findOne({ slug: orgSlug });
  if (!org) throw new Error("Organization not found");
 
  return {
    // Toutes les méthodes forcent le filtre organizationId
    collection: <T>(name: string) => ({
      find: (filter = {}) =>
        db.collection<T>(name).find({ ...filter, organizationId: org._id }),
      findOne: (filter = {}) =>
        db.collection<T>(name).findOne({ ...filter, organizationId: org._id }),
      insertOne: (doc: Omit<T, "organizationId">) =>
        db.collection<T>(name).insertOne({ ...doc, organizationId: org._id } as T),
    }),
    org,
  };
}

Gestion des plans et feature flags

// lib/billing/plans.ts
export const PLANS = {
  free: {
    name: "Free",
    limits: { users: 3, projects: 5, apiCalls: 1000 },
    features: { aiAssistant: false, advancedAnalytics: false, sso: false },
  },
  pro: {
    name: "Pro",
    limits: { users: 20, projects: 50, apiCalls: 50000 },
    features: { aiAssistant: true, advancedAnalytics: true, sso: false },
  },
  enterprise: {
    name: "Enterprise",
    limits: { users: Infinity, projects: Infinity, apiCalls: Infinity },
    features: { aiAssistant: true, advancedAnalytics: true, sso: true },
  },
} as const;
 
export type Plan = keyof typeof PLANS;
 
// Hook pour vérifier les features dans les Server Components
export async function hasFeature(
  orgSlug: string,
  feature: keyof typeof PLANS.free.features
): Promise<boolean> {
  const org = await getOrgBySlug(orgSlug);
  return PLANS[org.plan as Plan].features[feature];
}

Server Components et cache granulaire

Next.js 15 avec use cache (expérimental) permet un cache très granulaire au niveau des composants. Nous l'utilisons pour les données organisationnelles qui changent peu.

// app/(dashboard)/[orgSlug]/page.tsx
import { use, cache } from "react";
import { unstable_cache } from "next/cache";
 
const getOrgStats = unstable_cache(
  async (orgId: string) => {
    const { collection } = await getTenantDb();
    const [userCount, projectCount] = await Promise.all([
      collection("users").countDocuments(),
      collection("projects").countDocuments({ status: "active" }),
    ]);
    return { userCount, projectCount };
  },
  ["org-stats"],
  { revalidate: 60, tags: ["org-stats"] }
);
 
export default async function DashboardPage({
  params,
}: {
  params: Promise<{ orgSlug: string }>;
}) {
  const { orgSlug } = await params;
  const stats = await getOrgStats(orgSlug);
 
  return <DashboardView stats={stats} />;
}

Webhooks Stripe pour la gestion des abonnements

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { db } from "@/lib/db/mongodb";
 
export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature")!;
 
  const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
 
  switch (event.type) {
    case "customer.subscription.updated":
    case "customer.subscription.created": {
      const subscription = event.data.object as Stripe.Subscription;
      const priceId = subscription.items.data[0].price.id;
      const plan = getPlanFromPriceId(priceId);
 
      await db.collection("organizations").updateOne(
        { stripeCustomerId: subscription.customer },
        { $set: { plan, subscriptionStatus: subscription.status } }
      );
      break;
    }
  }
 
  return Response.json({ received: true });
}

Conclusion

Cette architecture couvre les cas d'usage essentiels d'un SaaS B2B en production : isolation des données, gestion des droits, facturation et performances. La clé est de prendre ces décisions architecturelles tôt, car elles sont difficiles à refactorer une fois l'application en production avec des clients réels.

Klark Labs
Technology Studio