← Back to blog

How to Add Custom Domains to a Next.js Multi-Tenant SaaS (with Code)

Jonathan Geiger·
tutorialnextjsmulti-tenantcustom domainsvercelsaas

Most Next.js multi-tenant tutorials hand-wave the part where each tenant gets their own custom domain. They show the wildcard subdomain bit (acme.yourapp.com) and stop. Real production needs the next step. Customers want their own domains, not your subdomains.

This is the full tutorial: route a customer's shop.acme.com to your Next.js origin, look up which tenant owns it in middleware, serve them their content.

Architecture

[ visitor: shop.acme.com ]
        |
        v  TLS handshake (cert for shop.acme.com)
[ edge layer: terminates TLS, proxies to origin ]
        |
        v  X-Forwarded-Host: shop.acme.com
[ your Next.js origin (Vercel / Fly / Docker / wherever) ]
        |
        v  middleware.ts reads X-Forwarded-Host
[ tenant lookup → rewrite to /tenants/[tenantId]/... ]

The thing that does the cert + TLS + proxy is what we'll call the "edge layer." You have three reasonable options for it; we'll compare them later. The Next.js side is the same regardless.

Step 1 — Wildcard dev domain

For local + preview testing you want every fake tenant on a subdomain you control: acme.preview.yourapp.com, corp.preview.yourapp.com, etc.

Cheapest path: add a wildcard DNS record at your DNS provider:

*.preview.yourapp.com  CNAME  yourapp.vercel.app

If you're on Vercel, configure *.preview.yourapp.com as a wildcard domain in Project Settings → Domains. Vercel auto-issues a wildcard TLS cert. Every subdomain now hits your Next.js app.

Step 2 — Read the hostname in middleware

middleware.ts runs before any page request. It's the natural place to map hostname → tenant.

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

export function middleware(req: NextRequest) {
  // x-forwarded-host is set by Vercel / Cloudflare / Domainee edge.
  // host is the fallback for local dev.
  const host =
    req.headers.get("x-forwarded-host") ??
    req.headers.get("host") ??
    "";
  const hostname = host.split(":")[0]; // strip port for localhost:3000

  // Wildcard preview tenant: the slug is the leftmost label.
  if (hostname.endsWith(".preview.yourapp.com")) {
    const slug = hostname.replace(".preview.yourapp.com", "");
    const url = req.nextUrl.clone();
    url.pathname = `/tenants/${slug}${url.pathname}`;
    return NextResponse.rewrite(url);
  }

  // Otherwise it's a customer's custom domain — look it up.
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next|favicon).*)"],
};

That handles wildcard tenants. For custom domains we need a lookup.

Step 3 — Custom-domain lookup

When shop.acme.com hits your origin, you need to know it maps to tenant acme-42 before rendering. A database query in middleware is too slow (10-50ms each round trip). Use edge KV:

// middleware.ts
import { kv } from "@vercel/kv";

async function tenantForHostname(hostname: string): Promise<string | null> {
  const cached = await kv.get<string>(`tenant:${hostname}`);
  if (cached) return cached;

  // Cold path: query your DB, then cache.
  const tenant = await fetch(
    `https://api.yourapp.com/internal/tenant-by-host?host=${hostname}`,
    { headers: { Authorization: `Bearer ${process.env.INTERNAL_KEY}` } },
  ).then((r) => r.json());

  if (tenant) await kv.set(`tenant:${hostname}`, tenant.id, { ex: 3600 });
  return tenant?.id ?? null;
}

Warm the cache on tenant creation (or on a domain.verified webhook from your custom-domain provider). Invalidate when a domain is removed. The KV hit is < 5ms at the edge, which keeps middleware fast.

Step 4 — Provisioning the customer's hostname

Three options for the edge layer that holds the cert and proxies traffic. The code above doesn't change; only the provisioning POST does.

Option A: Vercel for Platforms

If you're already on Vercel and your scale is under ~100 hostnames, Vercel's domains.add() API works. Add a hostname to your project programmatically, Vercel issues TLS, traffic routes to your origin.

Trade-offs:

  • Tied to Vercel deployment
  • Per-hostname pricing rolled into Vercel plan; can get expensive at scale
  • Wildcard hostnames cost extra (Enterprise tier)
  • No buy-a-domain flow built in (new Registrar API is separate)

Option B: Cloudflare for SaaS

Cloudflare's hostname API issues certs and routes traffic. 100 hostnames free on every plan, then $0.10/hostname/month.

Trade-offs:

  • Apex domains need Enterprise tier (BYO IP)
  • Custom certs are Enterprise-only
  • No buy-a-domain integration if your customers need to buy a new domain
  • No MCP / AI tooling

Option C: Domainee

One POST per customer hostname:

await fetch("https://api.domainee.dev/v1/domains", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DOMAINEE_API_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    hostname: "shop.acme.com",
    originUrl: "https://yourapp.com",
    mode: "proxy",
    metadata: { tenantId: "acme-42" },
  }),
});

What you specifically get vs the other two:

  • 50 hostnames free, then $0.20/domain/month graduating to $0.10 at 10k+ domains
  • Apex domains work on all tiers (we return both CNAME and A records in the response)
  • Buy-a-Domain API on the same workspace and Bearer key
  • MCP server so AI agents can manage domains too
  • Published pricing curve all the way up; no contact-sales tier

The Next.js integration is identical to Cloudflare/Vercel. One POST per customer, one webhook subscription, done.

Step 5 — DNS verification UI

After registering the hostname, the customer needs to add a CNAME at their DNS provider. Show them what to add:

// app/(app)/domains/[id]/page.tsx
export default async function DomainPage({ params }) {
  const domain = await fetchDomain(params.id);

  return (
    <div>
      <h1>{domain.hostname}</h1>
      <p>Add this CNAME at your DNS provider:</p>
      <pre>
{`Type:  CNAME
Name:  ${domain.hostname}
Value: ${domain.dnsRecords[0].value}`}
      </pre>
      <p>Status: {domain.status}</p>
      <p>The domain will go live within ~1 minute of the CNAME resolving.</p>
    </div>
  );
}

Don't poll. Subscribe to the provider's webhook and flip the UI when domain.verified fires:

// app/api/webhooks/domains/route.ts
export async function POST(req: Request) {
  const event = await verifyAndParse(req);
  if (event.type === "domain.verified") {
    await kv.set(
      `tenant:${event.data.domain.hostname}`,
      event.data.domain.metadata.tenantId,
      { ex: 3600 },
    );
    // Notify the user the domain is live (email, in-app toast, etc.)
  }
  return Response.json({ ok: true });
}

Quick comparison

Vercel for PlatformsCloudflare for SaaSDomainee
Free tier50 / project on Hobby100 hostnames free50 hostnames free
Per-hostname after freerolled into Vercel plan$0.10/mo$0.20/mo → $0.10 @ 10k+
Apex domainsyesEnterprise onlyall tiers
Buy-a-domainnew Registrar API (separate)partial via CF Registraryes, same workspace
MCP / AI toolingnonoyes
Pricing transparencybundledpublishedpublished all the way up
Tied to your hosting?yes (Vercel)nono

The honest take: if you're committed to Vercel and you're going to stay small, Vercel for Platforms is fine. If you want decoupled infra and apex domains on day one, Domainee or Cloudflare. We built Domainee, so we'll just say: the apex story, the published pricing curve, and the MCP integration are why teams pick us over Cloudflare for SaaS specifically.

Ship it

The Next.js code (middleware + KV + rewrites) is identical regardless of edge provider. The only thing that changes is the one POST you send to provision a hostname.

50 hostnames free at domainee.dev/sign-up, no card. Mint a key, register your first customer's hostname, point your Next.js middleware at X-Forwarded-Host, done in about 100 lines of code.

For the Connect API spec see /docs/api/domains. For the broader Cloudflare-for-SaaS comparison see /blog/how-to-set-up-custom-domains-cloudflare-ssl-for-saas.

How to Add Custom Domains to a Next.js Multi-Tenant SaaS (with Code) | Domainee