Architecture SaaS avec Next.js
Comment structurer une application SaaS B2B scalable avec Next.js 15, du routing à la gestion multi-tenant.
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.