Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Custom domains for multi-tenant apps on Vercel

Let customers use their own domain on your Vercel app. Add the hostname with Vercel's API, then write the DNS records in their Cloudflare or Vercel account.

You run a multi-tenant app on Vercel, and a customer wants app.theirbrand.com to open their workspace. Two separate things have to happen. Vercel has to serve the hostname from your project, and the customer’s DNS has to point at Vercel. This guide wires both: Vercel’s API does the first, and DomainKit does the second.

Step Where it happens Who does it
Add the hostname to your Vercel project Your Vercel account Vercel’s API, called by your app
Issue the certificate Your Vercel account Vercel
Write the records that point it at you The customer’s DNS, on Vercel or Cloudflare DomainKit, after the customer approves

Vercel’s multi-tenant domain management covers the first two rows. DomainKit never touches your project, and it never issues certificates. It writes records in the customer’s own account, so it works the same whether the customer’s DNS is on Vercel or Cloudflare.

You need a TypeScript app with an API that can identify the signed-in customer, a Vercel API token with access to your project, and DomainKit mounted in that API. The custom domain setup guide covers the mount, and the Quickstart runs the lifecycle in memory first.

1. Add the hostname to your Vercel project

When the customer submits app.theirbrand.com, your app calls Vercel’s API to attach it to your project. This is Vercel’s endpoint, not a DomainKit call, and the account it writes to is yours.

/**
 * Vercel's API, not DomainKit, attaches the customer's hostname to your own Vercel project. The
 * response names the registrable domain (`apexName`) and any TXT proof Vercel still wants.
 */
export const addToProject = async (input: {
  readonly hostname: string;
  readonly token: string;
}) => {
  const response = await fetch(`https://api.vercel.com/v10/projects/${projectId}/domains`, {
    method: "POST",
    headers: vercelHeaders(input.token),
    body: JSON.stringify({ name: input.hostname }),
  });
  if (!response.ok) throw new Error(`Vercel refused ${input.hostname}: ${response.status}`);
  return (await response.json()) as {
    readonly name: string;
    readonly apexName: string;
    readonly verified: boolean;
    readonly verification?: ReadonlyArray<{
      readonly type: string;
      readonly domain: string;
      readonly value: string;
    }>;
  };
};

The response names the registrable domain, apexName, and says whether Vercel still wants proof of ownership. Vercel’s API reference lists every response field.

2. Ask Vercel where the hostname should point

A second Vercel call returns the recommended CNAME target for a subdomain or address for an apex domain. Read it at runtime instead of copying a value from a page, because Vercel can change it.

/**
 * Where Vercel says the hostname should point: a CNAME target for a subdomain, an address for an
 * apex domain. Read it from Vercel's domain configuration rather than hard-coding a value.
 */
export const recommendedRoute = async (input: {
  readonly hostname: string;
  readonly apexName: string;
  readonly token: string;
}) => {
  const response = await fetch(
    `https://api.vercel.com/v6/domains/${input.hostname}/config?projectIdOrName=${projectId}`,
    { headers: vercelHeaders(input.token) },
  );
  if (!response.ok)
    throw new Error(`Vercel config failed for ${input.hostname}: ${response.status}`);
  const config = (await response.json()) as {
    readonly recommendedCNAME?: ReadonlyArray<{ readonly rank: number; readonly value: string }>;
    readonly recommendedIPv4?: ReadonlyArray<{
      readonly rank: number;
      readonly value: ReadonlyArray<string>;
    }>;
  };
  const best = <A extends { readonly rank: number }>(options: ReadonlyArray<A> | undefined) =>
    [...(options ?? [])].sort((left, right) => left.rank - right.rank)[0];
  const address = best(config.recommendedIPv4)?.value[0];
  const cname = best(config.recommendedCNAME)?.value;
  const route =
    input.hostname === input.apexName ? (address ? { address } : null) : cname ? { cname } : null;
  if (route === null) throw new Error(`Vercel gave no route for ${input.hostname}`);
  return route;
};

3. Turn Vercel’s answers into records

Vercel’s answer is a set of DNS requirements: usually a CNAME that points the hostname at Vercel, and a TXT record when ownership isn’t proven yet. If you accept apex domains such as theirbrand.com, point them with an A record, since most DNS hosts don’t allow a CNAME at the apex.

/**
 * What Vercel told you the customer must add: where the hostname should point, and any TXT proof
 * Vercel asked for. Pass the values Vercel returned; do not copy them from a docs page.
 */
export interface HostnameInput {
  readonly hostname: string;
  readonly route: { readonly cname: string } | { readonly address: string };
  /** Vercel omits `verification` when it needs no ownership proof. */
  readonly verification?:
    | ReadonlyArray<{ readonly domain: string; readonly value: string }>
    | undefined;
}

export const requirements = (input: HostnameInput) => [
  "cname" in input.route
    ? DnsRecord.cname({
        name: input.hostname,
        target: input.route.cname,
        purpose: "Send traffic to your app",
      })
    : DnsRecord.a({
        name: input.hostname,
        address: input.route.address,
        purpose: "Send traffic to your app",
      }),
  ...(input.verification ?? []).map((record) =>
    DnsRecord.txt({
      name: record.domain,
      value: record.value,
      purpose: "Prove you own the domain",
    }),
  ),
];

The purpose is the sentence your customer reads next to each record.

4. Let the customer connect their DNS

The customer’s DNS may be on Vercel, on Cloudflare, or somewhere DomainKit can’t write to. Register both built-in providers, then ask which connection already reaches the apex domain before you show a provider list. Connect and attach apexName, not the subdomain: the records you plan include Vercel’s ownership TXT record at _vercel.<apexName>.

/**
 * Adding OAuth adds one method to the same definition. Scope ids come from the OAuth client you
 * registered with Cloudflare; the default set is `zone.read`, `dns.read`, `dns.write`,
 * `offline_access`.
 */
export const withOAuth = Cloudflare.provider({
  oauth: {
    clientId: Config.String("CF_CLIENT_ID"),
    clientSecret: Config.Redacted("CF_CLIENT_SECRET"),
  },
});
/**
 * A marketplace install redirects like OAuth but is not OAuth: the flow starts at the integration's
 * install URL and exchanges a one-time code at Vercel's token endpoint.
 */
export const withIntegration = Vercel.provider({
  integration: {
    clientId: Config.String("VERCEL_CLIENT_ID"),
    clientSecret: Config.Redacted("VERCEL_CLIENT_SECRET"),
    slug: "acme-domains",
  },
});
/**
 * Connect and attach the registrable domain (`apexName`), not the subdomain. Vercel's ownership
 * TXT record sits at `_vercel.<apexName>`, so the plan, the connection, and the later observation
 * all use the apex. `Resolved` means the customer already connected an account that reaches it.
 */
export const connectApex = (apexName: string) =>
  Effect.gen(function* () {
    const discovery = yield* Connect.discover(apexName);
    if (discovery._tag !== "Resolved") return discovery;
    return yield* Connect.attach({
      connectionId: discovery.connectionId,
      domain: apexName,
      target: discovery.target,
    });
  });

A connection belongs to the customer’s account, not the domain, so their next domain skips this step. The Vercel provider page covers tokens and the marketplace integration, and the Cloudflare provider page covers its OAuth flow.

If the customer’s DNS is elsewhere, show them the records from step 3 as a table to add by hand. DomainKit can still observe whether they resolve.

5. Plan, review, apply

Planning reads the customer’s zone and returns a Create, a Noop, or a Conflict for each record. It never writes. Plan against apexName, since every requirement must be at or below the planned domain. Keep one plan per apex: when the customer adds another hostname under it, call this again with every hostname together, because observation reads only the latest plan’s records.

/**
 * One plan per apex. Pass every hostname the customer has under it: a later plan replaces the
 * earlier one as the receipt that observation reads, so a second hostname rebuilds the plan from
 * all of them. The records already in the zone become no-ops. This step never writes.
 */
export const planForCustomer = (input: {
  readonly apexName: string;
  readonly hostnames: ReadonlyArray<HostnameInput>;
}) => {
  const records = new Map(
    input.hostnames
      .flatMap(requirements)
      .map((record) => [`${record._tag}|${record.name}|${DnsRecord.data(record)}`, record]),
  );
  return Provision.plan({ domain: input.apexName, requirements: [...records.values()] });
};

Show the customer the writes and any conflicts first. Their approval binds to the plan’s digest, and apply writes only what they approved, so run the two calls below on separate requests, with their decision between them. Writes go one record at a time and aren’t atomic, so a failure partway gives you a partial receipt whose outcomes name the record that failed. Planning again turns the records that landed into no-ops. Plans covers review, approval, and receipts.

/**
 * Two calls, with the customer's decision between them. Show `Plan.writes(plan)` first; run
 * `approveReviewedPlan` only when they press approve, then `applyApproved`. Apply re-plans and
 * fails `Stale` when the zone moved. A partial receipt is data: its `outcomes` say which write
 * failed and why.
 */
export const approveReviewedPlan = (planId: Plan.PlanId) => Provision.approve(planId);

export const applyApproved = (approval: Approval.Model) =>
  Effect.map(Provision.apply(approval), (receipt) => ({
    complete: Receipt.isComplete(receipt),
    written: Receipt.applied(receipt).length,
    outcomes: receipt.outcomes,
  }));

6. Wait for Vercel and DNS

Writing the records doesn’t make the hostname live. DomainKit observes the provider and public resolvers, and Vercel decides when the domain is verified and the certificate is issued. Run a job that checks the apex’s DNS, then asks Vercel and records its answer as host evidence. Host evidence never makes a domain ready on its own: until DNS has been observed, readiness stays pending. If the customer added the records by hand, or the plan isn’t applied yet, pass the records from step 3 as requirements; once a receipt exists, DNS is checked against it. The domain is ready only when the records resolve and every host status is ok. The job records ok only when Vercel has verified the domain and its configuration reports misconfigured: false, so a hostname isn’t called ready before it can serve HTTPS. Each hostname gets its own evidence row, and a failed request to Vercel is recorded as failed instead of waiting.

/**
 * Run this from your own job until the hostname works. It observes the apex's DNS first and attaches
 * Vercel's answer only afterwards. Pass `requirements` while nothing has been applied, when the
 * customer added the records by hand or the plan is not applied yet; once a receipt exists, DNS is
 * checked against it. Vercel, not DNS, decides when it serves the
 * hostname, so its answer is host evidence beside DomainKit's observation. The status is `ok` only
 * when Vercel has verified the domain and its configuration reports `misconfigured: false`. The
 * `source` carries the hostname, so two hostnames under one domain keep separate rows. A request
 * that errors or is rejected is `failed`, not a hostname that is merely waiting.
 */
export const recordVercelStatus = (input: {
  readonly apexName: string;
  readonly hostname: string;
  readonly token: string;
  readonly requirements?: ReadonlyArray<DnsRecord.Model>;
}) =>
  Effect.gen(function* () {
    // DNS first, so the returned readiness reflects this run's observation, not only Vercel's answer.
    yield* Verify.observe(
      input.requirements === undefined
        ? { domain: input.apexName }
        : { domain: input.apexName, requirements: input.requirements },
    );
    const ask = (url: string) =>
      Effect.tryPromise(async () => {
        const response = await fetch(url, { headers: vercelHeaders(input.token) });
        if (!response.ok) return { body: null, problem: `HTTP ${response.status}` };
        try {
          return { body: (await response.json()) as unknown, problem: null };
        } catch {
          return { body: null, problem: `HTTP ${response.status} with an unreadable body` };
        }
      }).pipe(
        Effect.catch((error) =>
          Effect.succeed({ body: null, problem: `request error: ${String(error)}` }),
        ),
      );
    const domain = yield* ask(
      `https://api.vercel.com/v9/projects/${projectId}/domains/${input.hostname}`,
    );
    const config = yield* ask(
      `https://api.vercel.com/v6/domains/${input.hostname}/config?projectIdOrName=${projectId}`,
    );
    const verified = (domain.body as { readonly verified?: boolean } | null)?.verified === true;
    const misconfigured =
      (config.body as { readonly misconfigured?: boolean } | null)?.misconfigured !== false;
    const failed = domain.body === null || config.body === null;
    const observedAt = yield* DateTime.now;
    return yield* Verify.attachEvidence({
      domain: input.apexName,
      evidence: [
        new Verify.HostEvidence({
          source: `vercel-domain:${input.hostname}`,
          status: failed ? "failed" : verified && !misconfigured ? "ok" : "pending",
          label: `Vercel serves ${input.hostname}`,
          detail: failed
            ? `Vercel request failed (${domain.problem ?? config.problem})`
            : verified && !misconfigured
              ? null
              : verified
                ? "Vercel reports the DNS as misconfigured"
                : "Vercel has not verified the domain yet",
          observedAt,
        }),
      ],
    });
  });

Verification covers observation and the schedule a worker can sleep on.

Before you go live

  • Your Vercel token stays on your server, and the project it can reach is the one you intend.
  • The hostname belongs to the signed-in customer’s tenant before you add it to Vercel.
  • You pass Vercel’s returned values into requirements, and never hard-code a target.
  • You show a manual table of records for customers whose DNS you can’t write to.
  • You remove a departed hostname from your Vercel project yourself. DomainKit’s cleanup handles only the customer’s records.

FAQ

Does DomainKit add the domain to my Vercel project?

No. Your app calls Vercel’s API for that. DomainKit writes records in the customer’s DNS account.

Does it issue the certificate?

No. Vercel issues and renews it after the hostname resolves to your project.

What if the customer's DNS is on Cloudflare?

It works the same. DomainKit has a Cloudflare provider alongside the Vercel one, and the customer connects their own account.

What about Cloudflare for SaaS?

That is another way to serve customer hostnames. See Cloudflare for SaaS and DomainKit.

Last updated on October 5, 2026

Was this page helpful?