DoDomain
Guides

Custom domains for a multi-tenant Next.js app

Route customer domains to the right tenant in Next.js 16, mint the DNS records with DoDomain, terminate TLS on Vercel or your own host, and decide up front what happens at the apex.

A multi-tenant SaaS on Next.js has three separate problems the moment a customer asks for app.acme.com instead of acme.yourproduct.com: your app has to know which tenant a hostname belongs to, the customer's DNS has to point that hostname at you and stay pointed, and something has to hold a certificate for a domain you do not own. Most guides stop after the first one. This page covers all three, in the order they bite, and ends with the two answers that are not obvious — where TLS terminates on a Next.js stack, and what to say when a customer wants the bare acme.com.

The examples use Next.js 16 (proxy.ts, the App Router) and @dodomain/node; nothing here depends on a particular database.

1. Resolve the tenant from the Host header

In Next.js 16 the request-time hook is proxy.ts at the project root (it was middleware.ts before 16). Read the hostname, look the tenant up, and rewrite the request into a tenant-scoped route segment so the rest of the app never touches the Host header again:

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

const PRIMARY_HOST = "yourproduct.com";

export function proxy(request: NextRequest) {
  // Behind a proxy or Vercel, the customer's hostname arrives in x-forwarded-host.
  const host = (request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "")
    .split(":")[0]
    .toLowerCase();

  if (host === PRIMARY_HOST || host === `www.${PRIMARY_HOST}`) {
    return NextResponse.next();
  }

  // A tenant on your own subdomain (acme.yourproduct.com) or a custom domain
  // (app.acme.com). Both rewrite to /_tenants/<host>/<path> — one route tree.
  const url = request.nextUrl.clone();
  url.pathname = `/_tenants/${host}${url.pathname}`;
  return NextResponse.rewrite(url);
}

export const config = {
  matcher: ["/((?!_next/|api/|favicon.ico).*)"],
};
app/_tenants/[host]/layout.tsx
import { notFound } from "next/navigation";
import { findTenantByHost } from "@/lib/tenants";

export default async function TenantLayout(props: {
  params: Promise<{ host: string }>;
  children: React.ReactNode;
}) {
  const { host } = await props.params;
  const tenant = await findTenantByHost(host);
  if (!tenant) notFound();
  return <>{props.children}</>;
}

findTenantByHost is a lookup against a table you own — one row per hostname a tenant is allowed to serve. The rest of this guide is about how rows get into that table honestly: not when a customer types a domain into a form, but when DNS actually resolves to you.

Do not trust the form

A customer typing app.acme.com into your settings page proves nothing. Store the domain as pending, and only mark it active — only let proxy.ts serve it — when a connection.verified webhook says the records are live. Otherwise a typo in a competitor's hostname routes their traffic to a 404 on your infrastructure.

2. Mint the records with a connect session

Your server mints one session per domain, describing the records your product needs. For a subdomain that is a single CNAME to your ingress hostname; add a TXT if you want an ownership proof you can check yourself.

app/api/domains/route.ts
import { DoDomain } from "@dodomain/node";
import { NextResponse, type NextRequest } from "next/server";
import { currentTenant, saveDomainRequest } from "@/lib/tenants";

const dodomain = new DoDomain({ secretKey: process.env.DODOMAIN_SECRET_KEY! });

export async function POST(request: NextRequest) {
  const tenant = await currentTenant(request);
  const { domain } = (await request.json()) as { domain: string };

  const session = await dodomain.sessions.create({
    domain,
    records: [{ type: "CNAME", host: "@", value: "cname.yourproduct.com" }],
    returnUrl: `https://${tenant.slug}.yourproduct.com/settings/domains`,
  });

  // pending until the webhook says otherwise
  await saveDomainRequest({ tenantId: tenant.id, domain, sessionId: session.id });

  return NextResponse.json({ connectUrl: session.connectUrl });
}

host: "@" means "the domain itself" — the domain you passed is app.acme.com, so the CNAME lands on app.acme.com, not on acme.com. Send the customer to session.connectUrl (or open the same session in a modal with @dodomain/react). On Cloudflare-hosted zones the record is written with one OAuth consent; on a provider that has enabled Domain Connect for DoDomain it is one click; everywhere else the hosted page shows that provider's exact panel steps and verifies each record live. You do not branch on any of that.

3. Activate the tenant from the webhook, not the redirect

The customer coming back to returnUrl means they closed the flow, not that DNS is live. The signal you act on is the signed connection.verified event:

app/api/webhooks/dodomain/route.ts
import { verifyWebhook, type WebhookEvent } from "@dodomain/node";
import { activateDomain, deactivateDomain } from "@/lib/tenants";

export async function POST(request: Request) {
  // The signature covers the exact bytes sent — read the raw body, never a re-serialized object.
  const rawBody = await request.text();
  const ok = verifyWebhook(
    process.env.DODOMAIN_WEBHOOK_SECRET!,
    rawBody,
    request.headers.get("x-dodomain-signature") ?? "",
  );
  if (!ok) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(rawBody) as WebhookEvent;
  switch (event.type) {
    case "connection.verified":
      // First verification, or recovery after a drift (data.recovered === true).
      await activateDomain(event.data.domain);
      break;
    case "connection.failed":
      // scope "connection": a verified domain drifted and stayed wrong for three
      // consecutive checks. scope "session": an attempt failed mid-flow — the
      // row is still pending, there is nothing to deactivate.
      if (event.data.scope === "connection") await deactivateDomain(event.data.domain);
      break;
    case "connection.disconnected":
      await deactivateDomain(event.data.domain);
      break;
  }
  return new Response(null, { status: 204 });
}

activateDomain is what flips the row proxy.ts reads. Dedupe on event.id (retries reuse it) and keep the handler idempotent — the webhooks page has the full event table, the signature format and the retry schedule.

Once active, the domain stays watched: DoDomain re-checks every connection's records against authoritative DNS on every plan, at no charge — every 10 minutes for the first day, hourly for the first week, every 6 hours after that. A customer who deletes the CNAME months later produces a connection.failed, which your handler turns into a deactivated row instead of a tenant silently serving a 404 on their own domain. Details in Automatic DNS drift monitoring.

4. Where TLS terminates on a Next.js stack

This is the part multi-tenant guides gloss over. DoDomain is a DNS layer and is never in your request path, so it holds no certificate for app.acme.com — the certificate is issued wherever your Next.js app terminates TLS, and connection.verified is the moment issuance can succeed, because DNS finally resolves to that host.

Vercel. Add the customer domain to your project — dashboard, or the Domains API from the same request handler that mints the session — and point the session's CNAME at the target Vercel gives you, typically cname.vercel-dns.com. Vercel issues and renews the certificate itself once DNS resolves. There is nothing to store on your side; the domain row in your database is only for proxy.ts.

Self-hosted Next.js behind Caddy. Caddy's on_demand_tls issues a certificate the first time it sees a new hostname, and its ask endpoint lets it check with you before doing so. Point ask at a tiny route that answers 200 only for active domains — the same table proxy.ts reads, populated by the webhook — and Caddy will never mint a certificate for a hostname that has not verified. The session's CNAME points at the Caddy host.

Cloudflare for SaaS. Create a custom hostname for the domain and have the session's CNAME point at your zone's fallback origin. Cloudflare validates through DNS and issues the edge certificate; your Next.js origin sees x-forwarded-host, which the proxy.ts above already prefers over host.

nginx, Render, Fly.io and the sequencing rule that holds for every host — add the domain on the platform, mint the session pointing at it, treat connection.verified as "issuance can proceed" — are on the SSL page.

5. Apex survival: what to say to "can I use acme.com itself?"

DNS forbids a CNAME at a zone's apex, so acme.com → cname.yourproduct.com is not a record any provider will accept, and DoDomain cannot create the provider-specific ALIAS/ANAME/flattened substitute for your customer. Decide the policy before the first customer asks, and enforce it in your form, not in the connect flow:

  1. Ask for a subdomain (app.acme.com, links.acme.com). One CNAME, every provider, every one-click path, and the sample proxy.ts needs no change. If the input equals its own zone, refuse it with the message that says what to do.
  2. Serve the apex from A/AAAA records if — and only if — your ingress has stable, documented IPs (an anycast edge, a load balancer with reserved addresses). Request them at "@" on acme.com. Those addresses become a contract with every connected customer; on Vercel the documented apex A record exists, on a self-hosted stack it is your own reserved IP.
  3. www plus a redirect at the provider. The customer points www.acme.com at you with a CNAME (verified and monitored), and sets apex-to-www forwarding in their DNS panel. Be explicit that the redirect is theirs: DoDomain neither creates it nor watches it.

The reasoning behind each, the CNAME-flattening providers, and the warnings a session returns for an apex request are on the Apex domains page.

Checklist

  • proxy.ts resolves the tenant from x-forwarded-host / host and serves only active rows.
  • Domains enter as pending; only connection.verified activates them; connection.failed and connection.disconnected deactivate them.
  • Certificates come from where TLS terminates (Vercel, Caddy, Cloudflare for SaaS, your own ACME client) — never from DoDomain.
  • The apex policy is decided in your form: subdomain by default, A/AAAA only with stable IPs, www + provider redirect as the fallback.

On this page