KlarkLabs
Back to blog
Architecture
10 December 2025
6 min read

SaaS Architecture with Next.js

How to structure a scalable B2B SaaS application with Next.js 15, from routing to multi-tenant management.

Next.jsSaaSArchitecture

Building a scalable B2B SaaS with Next.js 15 requires far more than a create-next-app scaffold. Multi-tenant architecture, plan management, data isolation, and production performance require structural decisions made from day one. Here is the architecture we run in production.

Project Structure

apps/
  web/                    # Main Next.js application
    app/
      (marketing)/        # Public pages (landing, pricing)
      (auth)/             # Login, signup, password reset
      (dashboard)/        # Authenticated zone
        [orgSlug]/        # Multi-tenant via dynamic segment
          settings/
          billing/
          [module]/
      api/                # Route handlers
    middleware.ts         # Auth + tenant routing
    components/
    lib/
      db/                 # Data access layer
      auth/               # Auth.js configuration
      billing/            # Stripe integration

Multi-tenancy via URL Slug

We opt for the slug-based model (app.example.com/acme/dashboard) rather than subdomain-based routing, which is simpler to deploy and compatible with all hosting environments without wildcard DNS requirements.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Extract orgSlug from 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));
  }
 
  // Verify the user belongs to this organization
  const hasAccess = session.user.organizations.some(
    (org) => org.slug === orgSlug
  );
 
  if (!hasAccess) {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // Inject orgSlug into headers for 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).*)"],
};

Data Isolation per Organization

Data isolation is critical for a B2B SaaS. We use a shared database, row-level isolation approach with MongoDB, wrapping all queries in a helper that enforces filtering by 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 {
    // All methods enforce the organizationId filter
    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,
  };
}

Plan Management and 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;
 
// Helper to check features in 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 and Granular Caching

Next.js 15 with unstable_cache allows very granular caching at the component level. We use it for organizational data that changes infrequently.

// app/(dashboard)/[orgSlug]/page.tsx
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} />;
}

Stripe Webhooks for Subscription Management

// 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

This architecture covers the essential use cases of a B2B SaaS in production: data isolation, permission management, billing, and performance. The key is to make these architectural decisions early, as they are difficult to refactor once the application is live with real customers.

Klark Labs
Technology Studio