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

Connect a customer's Vercel DNS

Let customers connect Vercel with an access token or a marketplace integration so your SaaS can add records to their Vercel DNS zones.

DomainKit writes DNS records to a customer’s own Vercel account, after they connect it and approve the exact records. It offers access tokens and, when you publish one, a marketplace integration. Both end at the same connection. Vercel’s Domains API attaches domains to your project and issues certificates, and the DomainKit adapter writes the customer’s DNS, so the two roles stay separate. The guide to custom domains for multi-tenant apps on Vercel walks through both halves in code.

Register the provider

/** Personal and team access tokens, with no integration to register. */
export const tokensOnly = Vercel.provider();
/**
 * 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",
  },
});

slug is your integration’s slug; the flow starts at vercel.com/integrations/<slug>/new and exchanges the one-time callback code at Vercel’s token endpoint. It redirects like OAuth, and it is not OAuth, so DomainKit models it as its own method rather than pretending the two are the same.

An integration has exactly one Redirect URL, so a development stage with its own origin cannot complete a real install. installOrigin moves the install page off https://vercel.com and baseUrl moves the code exchange and the REST API off https://api.vercel.com; point both at an emulator that serves /integrations/<slug>/new and Vercel’s API paths.

/**
 * A development stage whose browser cannot reach a registered install (one Redirect URL per
 * integration) points the install page and the API at an emulator that serves both.
 */
export const againstEmulator = Vercel.provider({
  baseUrl: "http://localhost:4000/vercel",
  integration: {
    clientId: "emulated-client",
    clientSecret: Config.Redacted("VERCEL_CLIENT_SECRET"),
    slug: "acme-domains",
    installOrigin: "http://localhost:4000/vercel",
  },
});

Derive registration requirements

/** Requirements for the provider console; this does not resolve credentials or verify a grant. */
export const registrationSettings = Server.registrationSettings({
  providers: [withIntegration],
  callbackBaseUrl: "https://app.acme.dev/api/domainkit",
});

Reuse callbackBaseUrl in your mounted server options. It includes your public origin and mount path; the output appends /callback/vercel. Derivation reads no credentials and makes no provider calls. It describes setup requirements, not console readiness or granted permissions. See the server reference for the output schema and failures.

The Integration output describes client_secret_post code exchange authentication and adds no OAuth scopes. Host-started installation resolves the required client id and secret before redirect, then resolves them again at completion so credential rotation remains possible. Empty credentials or slug and malformed install/API URLs fail field-specific InvalidInput before redirect or continuation storage. Configuration errors contain no rejected values or source error details. Install/API URLs support emulator mount paths and must be HTTP(S) without credentials, query, or fragment. The host still registers the callback and chooses integration permissions.

Connecting with a token

/** `teamId` limits discovery to one team; omit it to discover personal and accessible team zones. */
export const connectTeamToken = Connect.start({
  provider: "vercel",
  method: Connect.Method.token({ token: "vercel_token", teamId: "team_1" }),
  domain: "app.example.com",
});
Field Required Secret Means
token yes yes The access token
teamId no no Limit zone discovery to one team

The connection stores { teamId }. With a team id, it discovers that team’s zones. Without one, teamId is null and the connection discovers personal zones plus the zones of teams the token can reach. Omitting teamId does not restrict discovery to personal zones.

Each discovered target carries its own team context. DNS requests for a team target carry that target’s teamId; requests for a personal target do not.

Connecting with the integration

export const install = Effect.map(
  Connect.start({
    provider: "vercel",
    method: Connect.Method.integration({ returnTo: "/settings/domains" }),
    domain: "app.example.com",
  }),
  (started) => (started._tag === "Redirect" ? started.authorizationUrl : null),
);

Connect.start answers a Redirect to the install URL. When the customer finishes, Vercel drives the browser back to /callback/vercel with the installation’s team, which DomainKit checks against the token it exchanges: an install that reports a different team than the token belongs to fails Unauthenticated rather than connecting to the wrong account.

Zones and targets

A Vercel domain is a target when Vercel hosts its DNS. Each target carries the team that owns it and the nameservers Vercel reports, current or intended. Those nameservers are provider evidence, and they never stand in for an independent public DNS observation: a domain whose delegation has not moved yet reads correctly here and is still missing everywhere else.

The definition declares the vercel-dns.com nameserver suffix, so Connect.discover names Vercel as the host of a domain delegated there before the customer connects it.

Records

A, AAAA, CAA, CNAME, MX, NS, SRV, and TXT, through Vercel’s domain records API. A record type DomainKit does not model is kept as an opaque observation and never overwritten.

If you host your app on Vercel and need customers’ hostnames to resolve to it, read custom domains for multi-tenant apps on Vercel. A customer whose DNS is on Cloudflare instead is covered by the Cloudflare provider.

Adding a provider of your own is the write a provider guide.

Callback recovery

Host-started flows can opt in to a sanitized destination policy on the mounted server:

/** Connect through the mounted Server callback; re-inspect before planning DNS. */
export const callbackOptions: Server.Options = {
  callback: (outcome) =>
    Effect.succeed(
      outcome._tag === "Connected"
        ? undefined
        : `/settings/domains?connection=${outcome._tag === "Cancelled" ? "cancelled" : outcome.recovery}`,
    ),
};

Pass these options to Server.layer or Server.toWebHandler. Connected means a durable account connection, not DNS readiness; re-inspect and select the zone before planning. The policy receives only live, authenticated outcomes and validated return destinations. Cancellation recognizes only access_denied; other callback errors are Failed. Cancellation and failure retain the flow’s expiry and spending behavior. inspect can mean partial persistence and does not authorize replaying a one-time grant. See callback destinations for identity, same-origin, and failure boundaries.

Last updated on

Was this page helpful?