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.
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.
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.
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.
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 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.
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 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;
}
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",
};
}
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>
);
}
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 });
}
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.
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>
);
}
cache() function (which is request-scoped) or Next.js unstable_cache with tenant-specific cache keys. Never use plain module-level Map objects for tenant data in server components.matcher config, middleware runs on every request including _next/static files, adding latency to CSS and JS loads. Always exclude static paths in the matcher regex.loading.tsx for streaming and keeping layout data fetches fast (cache aggressively). Do not move page-level data fetching up into layouts just to parallelize—it breaks layout persistence.router.push must include the current orgId and projectId in the path. If these are forgotten, navigation breaks or routes to the wrong tenant. Extract these from the context provider or useParams() rather than passing them as props through multiple levels.notFound() for invalid tenant slugs: If getOrganization(orgId) returns null, the layout must call notFound(). Without this, the app renders with undefined organization data, producing cryptic errors or blank pages instead of a clean 404.params is a Promise. Accessing params.orgId directly without await returns undefined. Always destructure from await params.