Next.js App Router with Dynamic Tenant Routing

Frontend Engineering intermediate Building with AI ~22 min read

Overview

Multi-tenant SaaS applications serve many organizations from a single deployment, and each organization expects its own isolated workspace with its own data, users, and settings. The URL structure is the first expression of this isolation: a path like /acme-corp/proj-alpha/dashboard tells the user, the server, and the browser cache that this page belongs to a specific organization and project. Next.js App Router’s file-based routing with dynamic segments makes this URL structure declarative: a folder named [orgId] captures the organization slug, [projectId] captures the project, and nested layouts handle shared chrome and data loading at each tenancy level.

This pattern goes beyond simple parameterized routes. The tenant context established at the top-level layout propagates through nested server components, middleware validates tenant access before any page renders, API proxy routes forward requests to the backend with tenant headers injected, and server-side rendering pre-fetches tenant-specific data so the first paint shows real content rather than loading spinners.

As an example, a multi-tenant search dashboard application might use this architecture to serve multiple organizations, where each organization has isolated search indexes, query analytics, and configuration. The URL structure /[orgId]/[projectId]/... maps directly to the backend’s access control model, making authorization checks straightforward and URL-based deep linking natural.

Core Concepts

Dynamic Route Segments

A folder named [paramName] in the app/ directory creates a dynamic route segment. The value captured from the URL is available as params.paramName in the page’s server component. Nesting [orgId]/[projectId] creates a two-level dynamic hierarchy where every page underneath receives both parameters. This is the filesystem equivalent of route patterns like /:orgId/:projectId/* in traditional routers.

Nested Layouts for Tenant Context

Each dynamic segment can define its own layout.tsx that wraps all pages underneath. The org-level layout fetches organization metadata and renders the org navigation bar; the project-level layout fetches project settings and renders the project sidebar. These layouts persist across navigations within the same tenant—navigating from /acme/proj-a/dashboard to /acme/proj-a/settings re-renders only the page content, not the org or project chrome. This is both a performance optimization and a UX improvement.

Middleware-Based Tenant Validation

Next.js middleware runs on the edge before any route handler or layout executes. For multi-tenant apps, middleware extracts the orgId from the URL, validates the user’s session cookie, checks organization membership, and either allows the request or redirects to a login or “not a member” page. This single checkpoint protects every route under /[orgId]/ without requiring per-page authorization logic.

API Proxy Routes

Next.js route.ts files define server-side API handlers. In a multi-tenant app, these act as a proxy layer: the client makes requests to /api/[orgId]/[projectId]/search, and the route handler injects the tenant context (validated from the session) into the upstream API call. This keeps backend API keys and tenant tokens out of the client bundle and provides a uniform authentication boundary.

Server-Side Rendering with Tenant Data

Server components in App Router can await data fetches directly. A tenant dashboard page fetches the organization’s metrics, the project’s recent queries, and the user’s notification count in parallel using Promise.all before streaming the rendered HTML. The user sees real data on first paint, which is especially important for dashboards where stale-while-revalidate patterns would show outdated numbers.

How It Works

File-System Route Structure

The directory structure directly encodes the URL hierarchy. Each level can have its own layout, loading state, and error boundary.

app/
├── layout.tsx                    # Root layout (HTML shell, global providers)
├── middleware.ts                 # Edge middleware for auth + tenant validation
├── [orgId]/
│   ├── layout.tsx                # Org layout: nav bar, org context provider
│   ├── page.tsx                  # Org overview: /acme-corp
│   ├── settings/
│   │   └── page.tsx              # Org settings: /acme-corp/settings
│   └── [projectId]/
│       ├── layout.tsx            # Project layout: sidebar, project context
│       ├── page.tsx              # Project dashboard: /acme-corp/proj-alpha
│       ├── loading.tsx           # Streaming fallback for dashboard
│       ├── search/
│       │   └── page.tsx          # Search UI: /acme-corp/proj-alpha/search
│       ├── analytics/
│       │   └── page.tsx          # Analytics: /acme-corp/proj-alpha/analytics
│       └── settings/
│           └── page.tsx          # Project settings
└── api/
    └── [orgId]/
        └── [projectId]/
            ├── search/
            │   └── route.ts      # POST /api/acme-corp/proj-alpha/search
            └── ingest/
                └── route.ts      # POST /api/acme-corp/proj-alpha/ingest

Middleware for Tenant Access Control

Middleware intercepts every request under the tenant path prefix, validates the session, and checks that the authenticated user belongs to the requested organization.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySession } from "@/lib/auth";
import { checkOrgMembership } from "@/lib/tenancy";

export const config = {
  // Match all routes under /[orgId] except static assets and API routes
  matcher: ["/((?!_next|api|favicon.ico|public).*)"],
};

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Extract orgId from the first path segment
  const segments = pathname.split("/").filter(Boolean);
  if (segments.length === 0) {
    return NextResponse.next(); // Landing page, no tenant context
  }

  const orgId = segments[0];

  // Verify the user's session
  const session = await verifySession(request.cookies);
  if (!session) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("redirect", pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Check organization membership
  const isMember = await checkOrgMembership(session.userId, orgId);
  if (!isMember) {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }

  // Inject tenant context as headers for downstream server components
  const response = NextResponse.next();
  response.headers.set("x-org-id", orgId);
  response.headers.set("x-user-id", session.userId);
  response.headers.set("x-user-role", session.role);

  return response;
}

Organization-Level Layout

The org layout fetches organization metadata and provides it to all child routes via React context. It also renders the top navigation bar that persists across project navigations.

// app/[orgId]/layout.tsx
import { notFound } from "next/navigation";
import { getOrganization } from "@/lib/api/organizations";
import { OrgProvider } from "@/contexts/OrgContext";
import { OrgNavBar } from "@/components/OrgNavBar";

interface OrgLayoutProps {
  children: React.ReactNode;
  params: Promise<{ orgId: string }>;
}

export default async function OrgLayout({ children, params }: OrgLayoutProps) {
  const { orgId } = await params;
  const org = await getOrganization(orgId);

  if (!org) {
    notFound();
  }

  return (
    <OrgProvider org={org}>
      <OrgNavBar org={org} />
      <main className="org-content">{children}</main>
    </OrgProvider>
  );
}

// Generate metadata dynamically based on the organization
export async function generateMetadata({
  params,
}: {
  params: Promise<{ orgId: string }>;
}) {
  const { orgId } = await params;
  const org = await getOrganization(orgId);
  return {
    title: org ? `${org.name} | SearchApp` : "Not Found",
  };
}

Project-Level Layout with Sidebar

The project layout adds a second level of context and navigation. It receives both orgId and projectId from the URL parameters.

// app/[orgId]/[projectId]/layout.tsx
import { notFound } from "next/navigation";
import { getProject } from "@/lib/api/projects";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { ProjectSidebar } from "@/components/ProjectSidebar";

interface ProjectLayoutProps {
  children: React.ReactNode;
  params: Promise<{ orgId: string; projectId: string }>;
}

export default async function ProjectLayout({
  children,
  params,
}: ProjectLayoutProps) {
  const { orgId, projectId } = await params;
  const project = await getProject(orgId, projectId);

  if (!project) {
    notFound();
  }

  return (
    <ProjectProvider project={project}>
      <div className="project-layout">
        <ProjectSidebar orgId={orgId} projectId={projectId} />
        <div className="project-main">{children}</div>
      </div>
    </ProjectProvider>
  );
}

API Proxy Route with Tenant Injection

The API route handler validates the request, extracts tenant context, and forwards the search query to the backend API with the appropriate credentials.

// app/api/[orgId]/[projectId]/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/lib/auth";
import { getProjectApiKey } from "@/lib/api/projects";

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ orgId: string; projectId: string }> }
) {
  const { orgId, projectId } = await params;

  // Verify authentication
  const session = await verifySession(request.cookies);
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Get the project's backend API key (stored server-side, never exposed to client)
  const apiKey = await getProjectApiKey(orgId, projectId);
  if (!apiKey) {
    return NextResponse.json({ error: "Project not found" }, { status: 404 });
  }

  const body = await request.json();

  // Forward to the backend search API with tenant credentials
  const upstream = await fetch(
    `${process.env.SEARCH_API_URL}/v1/indexes/${projectId}/search`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${apiKey}`,
        "X-Org-Id": orgId,
      },
      body: JSON.stringify(body),
    }
  );

  const data = await upstream.json();
  return NextResponse.json(data, { status: upstream.status });
}

Practical Applications

In practice, this architecture can serve hundreds of organizations, each with multiple search projects. The URL /acme-corp/product-search/analytics loads the analytics dashboard for Acme Corp’s product search index. The org layout caches organization metadata in a React Server Component, so navigating between projects within the same org does not re-fetch the org data. The project layout loads index configuration (field mappings, ranking rules) that the search and analytics pages both consume.

The API proxy pattern is essential for this deployment model. Each project has its own search API key with scoped permissions. Storing these keys server-side in the route handler means the client never sees them, and the proxy can add rate limiting, request logging, and response transformation without changing the client code. The same proxy routes serve both the dashboard UI and the API explorer tool within the settings page.

Server-side rendering with tenant data gives the dashboard near-instant perceived load times. The project dashboard page fetches query volume, error rate, and latency percentiles in parallel as server components, and Next.js streams each section as it resolves. Users on fast connections see the full dashboard in a single round trip; users on slow connections see progressive rendering as each data section arrives.

Parallel Data Fetching in Server Components

The dashboard page demonstrates parallel server-side fetching. Each data source is independent, so Promise.all loads them concurrently rather than sequentially.

// app/[orgId]/[projectId]/page.tsx
import { getQueryVolume, getErrorRate, getLatencyP99 } from "@/lib/api/analytics";
import { getRecentQueries } from "@/lib/api/search";
import { MetricCard } from "@/components/MetricCard";
import { QueryList } from "@/components/QueryList";

interface DashboardProps {
  params: Promise<{ orgId: string; projectId: string }>;
}

export default async function ProjectDashboard({ params }: DashboardProps) {
  const { orgId, projectId } = await params;

  const [volume, errorRate, latency, recentQueries] = await Promise.all([
    getQueryVolume(orgId, projectId),
    getErrorRate(orgId, projectId),
    getLatencyP99(orgId, projectId),
    getRecentQueries(orgId, projectId, { limit: 20 }),
  ]);

  return (
    <div className="dashboard">
      <h1>Dashboard</h1>
      <div className="metrics-grid">
        <MetricCard label="Queries (24h)" value={volume} />
        <MetricCard label="Error Rate" value={`${errorRate}%`} />
        <MetricCard label="P99 Latency" value={`${latency}ms`} />
      </div>
      <QueryList queries={recentQueries} />
    </div>
  );
}

Common Pitfalls

Further Reading