Back to Blog

Next.js Multi-Tenant Subdomains: Middleware Routing, Per-Tenant Theming, and DNS Setup

Build multi-tenant subdomains in Next.js. Covers middleware-based subdomain routing, per-tenant configuration from database, dynamic theming with CSS variables, custom domain support, and Vercel DNS setup.

Viprasol Tech Team
March 23, 2027
13 min read

Subdomain multi-tenancy โ€” where each customer gets acme.yourapp.com โ€” is one of the most compelling SaaS UX patterns. It gives customers a sense of ownership, enables per-tenant branding, and makes it trivial to isolate tenant contexts. Next.js middleware makes this surprisingly clean to implement.

This guide builds the complete subdomain tenant system: middleware routing, tenant config from database, dynamic theming, and custom domain (app.acme.com) support.

Architecture Overview

Request: acme.yourapp.com/projects/123
    โ†“
Next.js Middleware (edge)
    โ†“
Extract hostname โ†’ "acme"
    โ†“
Rewrite to /t/acme/projects/123 (internal URL)
    โ†“
App Router picks up [tenant] segment
    โ†“
Server Component fetches tenant config
    โ†“
Render with tenant theme and data

The key insight: middleware rewrites the URL internally without changing what the user sees. acme.yourapp.com/projects/123 shows in the browser but renders /t/acme/projects/123 internally.

Middleware: Subdomain Routing

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

// Routes that don't need tenant resolution
const PUBLIC_ROUTES = [
  "/",
  "/pricing",
  "/blog",
  "/about",
  "/auth",
  "/api/auth",
  "/_next",
  "/favicon.ico",
  "/images",
];

const APP_DOMAIN = process.env.NEXT_PUBLIC_APP_DOMAIN!; // "yourapp.com"

export function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const hostname = req.headers.get("host") ?? "";

  // Strip port in development
  const host = hostname.replace(/:3000$/, "").replace(/:8080$/, "");

  // Case 1: Main app domain (yourapp.com) โ€” no tenant rewrite needed
  if (host === APP_DOMAIN || host === `www.${APP_DOMAIN}`) {
    return NextResponse.next();
  }

  // Case 2: Subdomain (acme.yourapp.com)
  if (host.endsWith(`.${APP_DOMAIN}`)) {
    const subdomain = host.replace(`.${APP_DOMAIN}`, "");

    // Skip system subdomains
    if (["www", "app", "api", "admin", "mail"].includes(subdomain)) {
      return NextResponse.next();
    }

    // Skip public routes on the main domain pattern
    const path = url.pathname;
    if (PUBLIC_ROUTES.some((r) => path === r || path.startsWith(r + "/"))) {
      return NextResponse.next();
    }

    // Rewrite: /projects/123 โ†’ /t/acme/projects/123
    url.pathname = `/t/${subdomain}${url.pathname}`;
    return NextResponse.rewrite(url);
  }

  // Case 3: Custom domain (app.acme.com)
  // Look up tenant by custom domain โ€” stored in DB, cached in KV
  return handleCustomDomain(req, host, url);
}

async function handleCustomDomain(
  req: NextRequest,
  host: string,
  url: URL
): Promise<NextResponse> {
  // Check KV cache for custom domain โ†’ tenant mapping
  const tenantSlug = await lookupCustomDomain(host);

  if (!tenantSlug) {
    // Unknown domain โ€” redirect to main app
    return NextResponse.redirect(new URL(`https://${APP_DOMAIN}`));
  }

  url.pathname = `/t/${tenantSlug}${url.pathname}`;
  return NextResponse.rewrite(url);
}

async function lookupCustomDomain(host: string): Promise<string | null> {
  // Use Vercel KV or Upstash Redis for fast edge lookup
  const cacheKey = `custom-domain:${host}`;

  // With Vercel KV:
  // const cached = await kv.get<string>(cacheKey);
  // if (cached !== null) return cached;

  // Fall back to API route (runs on origin, not edge)
  try {
    const res = await fetch(
      `${process.env.INTERNAL_API_URL}/api/internal/custom-domains/${encodeURIComponent(host)}`,
      {
        headers: { "x-internal-token": process.env.INTERNAL_API_TOKEN! },
        next: { revalidate: 300 }, // Cache for 5 minutes at edge
      }
    );
    if (!res.ok) return null;
    const data = await res.json();
    return data.tenantSlug ?? null;
  } catch {
    return null;
  }
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

App Router: Tenant Layout

// app/t/[tenant]/layout.tsx
import { notFound } from "next/navigation";
import { getTenantConfig } from "@/lib/tenant/config";
import { TenantThemeProvider } from "@/components/tenant/theme-provider";
import { TenantNavbar } from "@/components/tenant/navbar";

interface TenantLayoutProps {
  children: React.ReactNode;
  params: { tenant: string };
}

export default async function TenantLayout({
  children,
  params,
}: TenantLayoutProps) {
  const config = await getTenantConfig(params.tenant);

  if (!config) notFound();
  if (!config.isActive) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <p className="text-gray-500">This workspace is not available.</p>
      </div>
    );
  }

  return (
    <TenantThemeProvider config={config}>
      <div className="min-h-screen bg-background">
        <TenantNavbar config={config} />
        <main>{children}</main>
      </div>
    </TenantThemeProvider>
  );
}

Tenant Configuration

// lib/tenant/config.ts
import { prisma } from "@/lib/prisma";
import { cache } from "react";

export interface TenantConfig {
  id: string;
  slug: string;
  name: string;
  logoUrl: string | null;
  faviconUrl: string | null;
  customDomain: string | null;
  isActive: boolean;
  plan: "free" | "starter" | "pro" | "enterprise";
  // Theming
  primaryColor: string;    // "#3b82f6"
  accentColor: string;     // "#8b5cf6"
  borderRadius: "none" | "sm" | "md" | "lg" | "full";
  fontFamily: "inter" | "geist" | "system";
  // Features enabled for this tenant
  features: {
    customDomain: boolean;
    ssoEnabled: boolean;
    apiAccess: boolean;
    advancedAnalytics: boolean;
  };
}

// React cache() deduplicates within a single request
export const getTenantConfig = cache(
  async (slug: string): Promise<TenantConfig | null> => {
    const workspace = await prisma.workspace.findUnique({
      where: { slug },
      select: {
        id: true,
        slug: true,
        name: true,
        logoUrl: true,
        faviconUrl: true,
        customDomain: true,
        isActive: true,
        plan: true,
        primaryColor: true,
        accentColor: true,
        borderRadius: true,
        fontFamily: true,
      },
    });

    if (!workspace) return null;

    return {
      ...workspace,
      features: getPlanFeatures(workspace.plan),
    };
  }
);

function getPlanFeatures(plan: string): TenantConfig["features"] {
  return {
    customDomain: ["pro", "enterprise"].includes(plan),
    ssoEnabled: plan === "enterprise",
    apiAccess: ["pro", "enterprise"].includes(plan),
    advancedAnalytics: ["pro", "enterprise"].includes(plan),
  };
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Dynamic Theme with CSS Variables

// components/tenant/theme-provider.tsx
import type { TenantConfig } from "@/lib/tenant/config";

// Hex to HSL conversion for Tailwind-compatible CSS vars
function hexToHsl(hex: string): string {
  const r = parseInt(hex.slice(1, 3), 16) / 255;
  const g = parseInt(hex.slice(3, 5), 16) / 255;
  const b = parseInt(hex.slice(5, 7), 16) / 255;

  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0, s = 0;
  const l = (max + min) / 2;

  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
      case g: h = ((b - r) / d + 2) / 6; break;
      case b: h = ((r - g) / d + 4) / 6; break;
    }
  }

  return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}

const BORDER_RADIUS_MAP = {
  none: "0",
  sm: "4px",
  md: "8px",
  lg: "12px",
  full: "9999px",
} as const;

interface TenantThemeProviderProps {
  config: TenantConfig;
  children: React.ReactNode;
}

export function TenantThemeProvider({
  config,
  children,
}: TenantThemeProviderProps) {
  const primaryHsl = hexToHsl(config.primaryColor);
  const accentHsl = hexToHsl(config.accentColor);
  const borderRadius = BORDER_RADIUS_MAP[config.borderRadius];

  const cssVars = `
    :root {
      --primary: ${primaryHsl};
      --accent: ${accentHsl};
      --radius: ${borderRadius};
      --font-sans: ${config.fontFamily === "geist" ? "Geist, " : ""}${config.fontFamily === "inter" ? "Inter, " : ""}system-ui, sans-serif;
    }
  `;

  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: cssVars }} />
      {/* Load custom font if needed */}
      {config.fontFamily === "geist" && (
        <link
          href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap"
          rel="stylesheet"
        />
      )}
      {children}
    </>
  );
}

Tenant Navbar

// components/tenant/navbar.tsx
import Image from "next/image";
import type { TenantConfig } from "@/lib/tenant/config";

export function TenantNavbar({ config }: { config: TenantConfig }) {
  return (
    <header className="h-14 border-b border-gray-200 flex items-center px-4 gap-4 bg-white">
      {config.logoUrl ? (
        <Image
          src={config.logoUrl}
          alt={`${config.name} logo`}
          width={120}
          height={32}
          className="h-8 w-auto object-contain"
        />
      ) : (
        <span
          className="text-lg font-bold"
          style={{ color: `hsl(var(--primary))` }}
        >
          {config.name}
        </span>
      )}

      <nav className="flex items-center gap-1 ml-4">
        <a
          href="#"
          className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
        >
          Projects
        </a>
        <a
          href="#"
          className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
        >
          Members
        </a>
        <a
          href="#"
          className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
        >
          Settings
        </a>
      </nav>
    </header>
  );
}

Custom Domain Setup

// app/api/workspaces/[id]/custom-domain/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

export async function POST(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await auth();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { domain } = await req.json();

  // Validate domain format
  if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/.test(domain)) {
    return NextResponse.json({ error: "Invalid domain format" }, { status: 400 });
  }

  // Check uniqueness
  const existing = await prisma.workspace.findFirst({
    where: { customDomain: domain, id: { not: params.id } },
  });
  if (existing) {
    return NextResponse.json({ error: "Domain already in use" }, { status: 409 });
  }

  // Add domain to Vercel project via API
  if (process.env.VERCEL_PROJECT_ID) {
    await addDomainToVercel(domain);
  }

  await prisma.workspace.update({
    where: { id: params.id },
    data: {
      customDomain: domain,
      customDomainVerified: false,
      customDomainAddedAt: new Date(),
    },
  });

  return NextResponse.json({
    domain,
    cnameTarget: `cname.yourapp.com`,
    instructions: `Add a CNAME record pointing ${domain} to cname.yourapp.com`,
  });
}

async function addDomainToVercel(domain: string): Promise<void> {
  await fetch(
    `https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ name: domain }),
    }
  );
}

DNS Configuration Guide

For Vercel deployments, instruct tenants:

Subdomain (app.acme.com):

Type: CNAME
Name: app
Value: cname.vercel-dns.com
TTL: 3600

Apex domain (acme.com):

Type: A
Name: @
Value: 76.76.21.21  (Vercel's IP)
TTL: 3600

Wildcard verification (your domain): Add to your Vercel project:

  • Domain: *.yourapp.com
  • Vercel auto-provisions SSL via Let's Encrypt wildcard cert

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic subdomain routing (middleware only)1 dev1โ€“2 days$300โ€“600
Subdomain + per-tenant config + theming1 dev1 week$1,500โ€“3,000
Full system (+ custom domains + Vercel API)1โ€“2 devs2โ€“3 weeks$4,000โ€“9,000
Enterprise (SSO per tenant, SAML, custom billing)2โ€“3 devs4โ€“8 weeks$15,000โ€“35,000

See Also


Working With Viprasol

Subdomain multi-tenancy looks simple until you hit custom domains, SSL provisioning, edge caching, and per-tenant auth. Our team has built SaaS platforms with subdomain tenancy, Vercel custom domain APIs, and per-tenant theming systems where customers can upload their own logo and set brand colours.

What we deliver:

  • Next.js middleware for subdomain and custom domain routing
  • Tenant config system with React cache() deduplication
  • Dynamic CSS variable theming without client-side flash
  • Custom domain setup flow with Vercel API integration
  • DNS setup documentation for your customers

Talk to our team about your multi-tenant architecture โ†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.