Back to Videos

How to Build Multi-Tenant Apps in Next.js • Without Middleware?

Learn how to build multi-tenant Next.js applications using rewrites instead of middleware for cleaner, more maintainable subdomain routing.

sashkode

This article shows you how to implement multi-tenant subdomain routing in Next.js using configuration-based rewrites instead of middleware. The approach is inspired by Vercel's Platforms Starter Kit but eliminates the complexity of runtime middleware in favor of declarative Next.js configuration.

Why Skip Middleware?

The Vercel Platforms Starter Kit uses middleware to extract subdomains from the request hostname and rewrite URLs accordingly. While this works, it introduces:

  • Runtime overhead — JavaScript executes on every request
  • Complexity — Subdomain logic is spread across middleware and routing
  • Maintenance burden — Changes require understanding both middleware and Next.js routing

Using Next.js rewrites moves this logic to the configuration layer where it belongs. No JavaScript runs, no middleware to debug, and the routing logic is centralized in next.config.ts.

The Core Concept

Instead of using middleware to parse the hostname and rewrite URLs, define rewrites in next.config.ts that:

  1. Match requests based on the host header
  2. Extract the subdomain as a route parameter
  3. Rewrite to the appropriate app directory path

This leverages Next.js's built-in rewrite system to handle multi-tenancy without custom code.

Implementation: Subdomain Configuration

The complete implementation lives in config/ssl/next-subdomains.ts. Here's how it works:

Detecting the Root Domain

First, determine the base domain dynamically based on the environment:

config/ssl/next-subdomains.ts
export const createSubdomainConfig = (env: VercelEnv, customDomains: string[] = []) => {
  // Detect root domain from Vercel environment variables or fallback to localhost
  const rootDomain = env.VERCEL_ENV 
    ? `(${env.VERCEL_URL}|${env.VERCEL_BRANCH_URL}|${env.VERCEL_PROJECT_PRODUCTION_URL}|${customDomains.join("|")})` 
    : "app.localhost";
  • Production/Preview: Uses Vercel environment variables (VERCEL_URL, VERCEL_PROJECT_PRODUCTION_URL)
  • Local development: Defaults to app.localhost
  • Custom domains: Supports additional domains via the customDomains parameter

Rewrite Rules

The configuration returns two key rewrites using Next.js's beforeFiles phase:

config/ssl/next-subdomains.ts
return {
  rewrites: () => ({
    beforeFiles: [
      {
        // Handle subdomain routing
        source: "/:path((?!api|_next|_static|_vercel|\\.well-known|.*\\.\\w+$).*)*",
        has: [{ type: "host", value: `(?<subdomain>.*).${rootDomain}` }],
        missing: [{ type: "host", value: `root.${rootDomain}` }],
        destination: "/:subdomain*/:path*",
      },
      {
        // Handle root domain routing
        source: "/:path((?!api|_next|_static|_vercel|\\.well-known|.*\\.\\w+$).*)*",
        has: [{ type: "host", value: `${rootDomain}` }],
        destination: "/root/:path*",
      },
    ],
  }),
};

Key details:

  1. source regex: Excludes Next.js internal paths (_next, _static), API routes (api), Vercel internals (_vercel), well-known URIs (.well-known), and static files (any path ending with a file extension like .jpg, .css)
  2. has condition: Matches requests with a subdomain in the hostname, extracting it as a named group
  3. missing condition: Prevents root.your-domain.com from being treated as a subdomain — root is reserved for the main domain path
  4. destination: Rewrites to /:subdomain/:path or /root/:path based on the matched rule

See the full implementation for the complete code.

Using the Configuration

In next.config.ts, import and apply the configuration:

next.config.ts
import { createSubdomainConfig } from "./config/ssl/next-subdomains";
import { env } from "~/env/server";

const { rewrites, redirects, allowedDevOrigins } = createSubdomainConfig(
  env, 
  ["sashkode.dev", "sashkode.app"] 
);

const nextConfig: NextConfig = {
  allowedDevOrigins,
  rewrites,
  redirects,
  // ... other config
};

The configuration also returns:

  • allowedDevOrigins: Enables CORS for local subdomain development
  • redirects: Redirects localhost to app.localhost in development

App Directory Structure

With rewrites configured, organize your app directory by subdomain:

layout.tsx
page.tsx # -> videos.app.localhost/my-video
layout.tsx
layout.tsx # Root layout (shared by all)

Each subdomain gets its own directory. The [subdomain] directory handles dynamic tenants.

Example: Static Subdomain Page

Here's a simplified example of a videos subdomain page (the actual implementation uses a re-export pattern):

app/videos/page.tsx
// Simplified example - the actual app uses a re-export pattern
// Real implementation: export { VideosPage as default } from "~/video/server/videos-page"
export default function VideosPage() {
  return (
    <div>
      <h1>Videos Subdomain</h1>
      {/* Your videos listing */}
    </div>
  );
}

Access at videos.app.localhost (development) or videos.your-domain.com (production).

Example: Dynamic Subdomain Page

The wildcard subdomain page captures any subdomain not explicitly defined. Here's the actual implementation from the repository:

app/[subdomain]/page.tsx
// The repository uses a custom Page helper, but here's the standard Next.js equivalent:
export default async function SubdomainPage({
  params,
}: {
  params: Promise<{ subdomain: string }>;
}) {
  const { subdomain } = await params; 

  return (
    <div>
      <h1>Subdomain: {subdomain}</h1>
      {/* Load tenant-specific data based on subdomain */}
    </div>
  );
}

See the full page implementation.

Cross-Subdomain Navigation

Navigating between subdomains requires full URLs. The SubdomainLink component makes this type-safe and seamless:

src/platform/client/components/subdomain-link.tsx
import { SubdomainLink } from "~/platform/client/components/subdomain-link";

// Link to root domain
<SubdomainLink subdomain="root" pathname="/about">
  About Us
</SubdomainLink>

// Link to videos subdomain
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "my-video" }}>
  Watch Video
</SubdomainLink>

// Link to dynamic subdomain
<SubdomainLink subdomain="acme">
  Acme Corp Dashboard
</SubdomainLink>

See the complete component for the full implementation including TypeScript types.

Local Development with Subdomains

Option 1: HTTP (Quick Start)

For basic testing, redirect localhost to app.localhost:

config/ssl/next-subdomains.ts
redirects: () => [
  {
    permanent: false,
    source: "/:path*",
    has: [{ type: "host", value: "localhost" }],
    destination: "http://app.localhost:3000/:path*",
  },
];

Access your app at:

  • http://app.localhost:3000 (root domain)
  • http://videos.app.localhost:3000 (videos subdomain)
  • http://tenant1.app.localhost:3000 (dynamic subdomain)

Option 2: HTTPS (Production-Like)

For a production-like environment with SSL, use Next.js's experimental HTTPS support with custom certificates:

Terminal
npm run dev:ssl

This runs:

package.json
{
  "scripts": {
    "predev:ssl": "./config/ssl/trust.sh",
    "dev:ssl": "next dev --experimental-https --experimental-https-ca ./config/ssl/certificates/RootCA.crt --experimental-https-key ./config/ssl/certificates/localhost.key --experimental-https-cert ./config/ssl/certificates/localhost.crt"
  }
}

The predev:ssl script installs the CA certificate, and dev:ssl starts Next.js with HTTPS enabled.

For detailed certificate setup, see the Local SSL/HTTPS in Next.js article.

Production Deployment on Vercel

1. Just Configure as a Wildcard Domain in Vercel

In the Vercel dashboard, add the wildcard domain:

*.your-domain.com

Vercel will automatically provision SSL certificates for all subdomains using Let's Encrypt.

www vs non-www domain

I prefer redirecting www.your-domain.comyour-domain.com (non-www) rather than the other way around. This keeps the base domain as the canonical URL and avoids needing an additional rewrite rule for www. If you prefer using www as your primary domain, you'll need to update the rewrites configuration to handle www requests appropriately.

2. Update Configuration (Optional)

If you added custom domains to createSubdomainConfig, Vercel's environment variables will already include your production URL:

next.config.ts
const { rewrites, redirects } = createSubdomainConfig(
  env, 
  ["your-domain.com"] // Optional: add custom domains for preview branches
);

Type Safety with TypeScript

The SubdomainLink component provides full TypeScript support for routes and parameters:

// Type error: "videos" subdomain doesn't have a /dashboard route
<SubdomainLink subdomain="videos" pathname="/dashboard">

// Valid: videos has a /[slug] route
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "intro" }}>

// Type error: missing required params
<SubdomainLink subdomain="videos" pathname="/[slug]">

// Valid: params provided
<SubdomainLink subdomain="videos" pathname="/[slug]" params={{ slug: "intro" }}>

The types are derived from Next.js's typed routes, ensuring links stay in sync with your app structure.

See the type definitions for the complete implementation or more on type safety in the Why I'm NOT switching to TanStack Start - Type safety in Next.js article.

Further Exploration

This implementation is part of a larger multi-tenant architecture. Explore related code:

Resources

How to Build Multi-Tenant Apps in Next.js • Without Middleware? | Videos | sashkode