# Custom domains in multi-tenant Next.js (https://dodomain.io/docs/guides/custom-domains-multi-tenant-nextjs)

Resolve the tenant from the Host header in a Next.js 16 proxy.ts, mint and verify the customer's DNS records, terminate TLS on Vercel, and settle 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`](https://dodomain.io/docs/node-sdk); nothing here depends on a particular database.

## 1. Resolve the tenant from the Host header [#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:

```ts title="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).*)"],
};
```

```tsx title="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.

<Callout title="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.
</Callout>

## 2. Mint the records with a connect session [#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.

```ts title="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`](https://dodomain.io/docs/react-sdk)). 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 [#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:

```ts title="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](https://dodomain.io/docs/webhooks) 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](https://dodomain.io/docs/webhooks#automatic-dns-drift-monitoring).

## 4. Where TLS terminates on a Next.js stack [#4-where-tls-terminates-on-a-nextjs-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](https://dodomain.io/docs/ssl).

## 5. Apex survival: what to say to "can I use acme.com itself?" [#5-apex-survival-what-to-say-to-can-i-use-acmecom-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](https://dodomain.io/docs/apex-domains) page.

## Checklist [#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.
