# TypeScript custom domains SDK (/docs)
Domain SDK handles the custom-domain lifecycle across Vercel, Cloudflare for SaaS, Railway, Render, and Netlify. Add a hostname, show your customer the exact DNS records, and track provider state until the domain is ready.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { vercel } from "@opencoredev/domain-sdk/vercel";
export const domains = createDomainClient({
provider: vercel({
token: process.env.VERCEL_TOKEN!,
projectId: process.env.VERCEL_PROJECT_ID!,
}),
});
```
Use Domain SDK when your SaaS needs to show exact DNS instructions, track provider-authoritative status, and keep provider-specific APIs out of the rest of your application. It does not register domains, host DNS, deploy apps, proxy traffic, or store tenant data.
## Read next [#read-next]
# Install Domain SDK for TypeScript (/docs/installation)
## 1. Install the package [#1-install-the-package]
```bash title="Terminal"
bun add @opencoredev/domain-sdk
```
Domain SDK is server-only. Keep it in API routes, server actions, workers, or another trusted backend.
### Install the agent skill [#install-the-agent-skill]
Give Codex, Claude Code, Cursor, and other compatible coding agents the Domain SDK workflow and safety rules:
```bash title="Terminal"
npx skills add opencoredev/domain-sdk --skill domain-sdk
```
The skills CLI asks which detected agents should receive the skill. Add `-g` to make it available across all of your projects.
## 2. Choose your provider [#2-choose-your-provider]
Use the platform that currently receives traffic for your application. Each adapter is a separate import and uses credentials scoped to one project, service, site, or zone.
## 3. Create the client [#3-create-the-client]
Put the client in a server-only module. Pick the tab that matches your provider; its provider page lists the required credentials and platform-specific behavior.
```ts title="src/server/domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { vercel } from "@opencoredev/domain-sdk/vercel";
export const domains = createDomainClient({
provider: vercel({
token: process.env.VERCEL_TOKEN!,
projectId: process.env.VERCEL_PROJECT_ID!,
}),
});
```
```ts title="src/server/domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { cloudflareSaaS } from "@opencoredev/domain-sdk/cloudflare";
export const domains = createDomainClient({
provider: cloudflareSaaS({
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
zoneId: process.env.CLOUDFLARE_ZONE_ID!,
cnameTarget: process.env.CLOUDFLARE_CNAME_TARGET!,
}),
});
```
```ts title="src/server/domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { railway } from "@opencoredev/domain-sdk/railway";
export const domains = createDomainClient({
provider: railway({
token: process.env.RAILWAY_TOKEN!,
projectId: process.env.RAILWAY_PROJECT_ID!,
environmentId: process.env.RAILWAY_ENVIRONMENT_ID!,
serviceId: process.env.RAILWAY_SERVICE_ID!,
}),
});
```
```ts title="src/server/domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { render } from "@opencoredev/domain-sdk/render";
export const domains = createDomainClient({
provider: render({
apiKey: process.env.RENDER_API_KEY!,
serviceId: process.env.RENDER_SERVICE_ID!,
}),
});
```
```ts title="src/server/domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { netlify } from "@opencoredev/domain-sdk/netlify";
export const domains = createDomainClient({
provider: netlify({
accessToken: process.env.NETLIFY_ACCESS_TOKEN!,
siteId: process.env.NETLIFY_SITE_ID!,
}),
});
```
Never expose provider credentials through `NEXT_PUBLIC_`, `VITE_`, or another browser-accessible environment variable.
## 4. Add and refresh a domain [#4-add-and-refresh-a-domain]
```ts title="src/server/customer-domain.ts"
import { domains } from "./domains";
const domain = await domains.add("app.customer.com");
// Show every required record in your customer-facing DNS instructions.
const requiredRecords = domain.records.filter((record) => record.required);
const latest = await domains.refresh(domain.hostname);
if (latest.status === "active") {
// The configured provider now reports the domain as ready.
}
```
Store the hostname's tenant ownership in your own database. Domain SDK is stateless and the configured provider remains the source of truth for readiness.
Read next: [Compare providers](./providers/index.mdx).
# createDomainClient (/docs/api-reference/create-domain-client)
```ts title="Signature"
createDomainClient(options: { provider: DomainProvider; logger?: DomainLogger }): DomainClient
```
The returned client exposes stable provider ID and capability metadata. Logger methods receive operational messages and sanitized context; hostnames may appear because they are operational identifiers. The SDK never logs credentials.
Read next: [`DomainClient`](./domain-client.mdx).
# createSubdomainClient (/docs/api-reference/create-subdomain-client)
```ts title="Signature"
createSubdomainClient(options: {
domainClient: DomainClient;
baseDomain: string;
reservedLabels?: readonly string[];
}): SubdomainClient
```
| Member | Returns | Behavior |
| ---------------------------------------------------- | --------- | ------------------------------------------------------------- |
| `baseDomain` | `string` | Normalized parent hostname. |
| `wildcardHostname` | `string` | Normalized `*.baseDomain` hostname. |
| `supportsWildcard` | `boolean` | Selected provider's wildcard capability. |
| `toHostname(label)` | `string` | Normalize and constrain a direct-child label. |
| `fromHostname(hostname)` | `string` | Validate the parent boundary and return its normalized label. |
| `add/get/refresh/verify/remove(label)` | lifecycle | Run the matching operation for one tenant hostname. |
| `waitUntilActive(label)` | `Domain` | Poll one tenant hostname until active. |
| `provisionWildcard()` and wildcard lifecycle methods | lifecycle | Add, get, refresh, verify, or remove the wildcard hostname. |
| `waitUntilWildcardActive()` | `Domain` | Poll the wildcard hostname until active. |
Labels must contain exactly one DNS label. Reserved labels, wildcards, nested subdomains, and hostnames outside `baseDomain` are rejected before any provider request.
The client does not allocate labels or store tenant ownership. Claim the normalized label with a unique database constraint and authorize all changes in the application layer.
Read next: [Give tenants managed subdomains](../guides/managed-subdomains.mdx).
# DnsRecord (/docs/api-reference/dns-record)
```ts title="Type"
interface DnsRecord {
type: "A" | "AAAA" | "CNAME" | "TXT" | "CAA" | "ALIAS" | "ANAME";
name: string;
value: string;
ttl?: number;
purpose: "routing" | "ownership" | "certificate" | "other";
required: boolean;
status: "pending" | "valid" | "invalid" | "unknown";
description?: string;
}
```
Read next: [Display DNS records correctly](../guides/display-dns-records.mdx).
# DomainClient (/docs/api-reference/domain-client)
| Member | Returns | Behavior |
| ------------------------------------- | ------------ | ---------------------------------------- |
| `provider` | `string` | Stable provider ID. |
| `capabilities` | metadata | Optional-operation support. |
| `add(hostname, options?)` | `Domain` | Add or safely return an existing domain. |
| `get(hostname, options?)` | `Domain` | Retrieve current provider state. |
| `refresh(hostname, options?)` | `Domain` | Alias for a fresh get. |
| `list(options?)` | `DomainPage` | List with `limit` 1–100 and cursor. |
| `verify(hostname, options?)` | `Domain` | Explicitly verify when supported. |
| `remove(hostname, options?)` | `void` | Safely remove; absent is success. |
| `waitUntilActive(hostname, options?)` | `Domain` | Sequentially poll until active. |
Network-aware options accept `signal?: AbortSignal`.
Read next: [`Domain`](./domain.mdx).
# DomainProvider (/docs/api-reference/domain-provider)
```ts title="Contract"
interface DomainProvider {
readonly id: string;
readonly capabilities: DomainProviderCapabilities;
add(input, context): Promise;
get(input, context): Promise;
list?(input, context): Promise;
verify?(input, context): Promise;
remove(input, context): Promise;
}
```
`ProviderContext` contains only an abort signal and logger. Adapters own fixed API origins and credential-bearing fetch calls.
Read next: [Contributing](../project/contributing.mdx).
# DomainSdkError (/docs/api-reference/domain-sdk-error)
Fields are `code`, optional `provider`, optional `statusCode`, `retryable`, optional `retryAfter` in milliseconds, bounded sanitized `details`, and optional safe `cause`.
Codes: `AUTHENTICATION_FAILED`, `PERMISSION_DENIED`, `INVALID_CONFIGURATION`, `INVALID_HOSTNAME`, `DOMAIN_CONFLICT`, `DOMAIN_NOT_FOUND`, `UNSUPPORTED_OPERATION`, `VERIFICATION_FAILED`, `RATE_LIMITED`, `PROVIDER_UNAVAILABLE`, `REQUEST_FAILED`, `TIMEOUT`, `ABORTED`, and `UNKNOWN_ERROR`.
Read next: [Error handling](../concepts/error-handling.mdx).
# DomainStatus (/docs/api-reference/domain-status)
```ts title="Type"
type DomainStatus =
| "pending"
| "pending_dns"
| "pending_verification"
| "pending_certificate"
| "active"
| "misconfigured"
| "failed"
| "unknown";
```
Treat only `active` as production-ready. See [Status model](../concepts/status-model.mdx) for semantics.
# Domain (/docs/api-reference/domain)
| Field | Type | Notes |
| ------------------------ | -------------------- | -------------------------------------------------- |
| `id` | `string` | Provider-resource identifier safe for persistence. |
| `hostname` | `string` | Normalized lowercase ASCII hostname. |
| `provider` | `string` | Stable adapter ID. |
| `status` | `DomainStatus` | Overall readiness. |
| `records` | `DnsRecord[]` | Deduplicated actionable DNS. |
| `verification` | `DomainVerification` | Ownership state and records. |
| `certificate` | `DomainCertificate` | Managed certificate state. |
| `issues` | `DomainIssue[]` | Safe actionable failures. |
| `createdAt`, `updatedAt` | `Date?` | Present only when exposed. |
Raw provider bodies and credentials are never included.
Read next: [`DnsRecord`](./dns-record.mdx).
# Domain SDK TypeScript API reference (/docs/api-reference)
The Domain SDK API reference documents the public TypeScript exports for managing customer domains. Start with `createDomainClient`, then use the normalized domain types and lifecycle methods regardless of which platform adapter you configure.
## Clients and provider contract [#clients-and-provider-contract]
## Models and utilities [#models-and-utilities]
# Testing utilities (/docs/api-reference/testing-utilities)
`memoryProvider(options?)` supports initial status, records, certificate state, activation delay, latency, and per-operation failures. Instances expose `activate`, `transition`, `reset`, and immutable call inspection.
`createMockDomain`, `createMockDnsRecord`, and `createFailingProvider` generate focused test values. Import all of them from `@opencoredev/domain-sdk/testing`; production root imports remain unaffected.
Read next: [Test without external API calls](../guides/test-without-network.mdx).
# waitUntilActive (/docs/api-reference/wait-until-active)
```ts title="Signature"
waitUntilActive(hostname: string, options?: {
timeoutMs?: number; // default 300000
intervalMs?: number; // default 5000, minimum 250
signal?: AbortSignal;
onStatus?(domain: Domain): void;
}): Promise
```
The first check runs immediately. Retryable errors continue with the larger of interval and `retryAfter`; permanent errors stop. The deadline throws retryable `TIMEOUT`.
Read next: [Poll until active](../guides/poll-until-active.mdx).
# DNS records UI component (/docs/components/dns-records-table)
## Install [#install]
```bash title="Terminal"
bunx --bun shadcn@latest add https://domain-sdk.dev/r/dns-records-table.json
```
## Usage [#usage]
Pass the normalized records returned by Domain SDK. Your application owns refreshing and persistence.
```tsx title="domain-settings.tsx"
import { DnsRecordsTable } from "@/components/domain/dns-records-table";
;
```
The component uses shadcn `Table`, `Badge`, `Button`, and `NativeSelect` with Hugeicons. Names and values are full-cell copy targets, optional records can be filtered, and every copy is confirmed through a polite live region.
When your server discovers a supported Domain Connect path, place the separately installable [One-click DNS Setup](./one-click-dns-setup.mdx) component before this manual fallback.
# Custom domain UI components (/docs/components)
Domain SDK components turn normalized domain records into customer-facing setup interfaces. Use them with your own authenticated server endpoint so provider credentials and tenant authorization stay outside the browser.
For the complete server and client flow, follow [Build a custom-domain settings page](/docs/guides/custom-domain-settings-page).
# One-click DNS setup component (/docs/components/one-click-dns-setup)
## Install [#install]
```bash title="Terminal"
bunx --bun shadcn@latest add https://domain-sdk.dev/r/one-click-dns-setup.json
```
## Usage [#usage]
Render this component only after your server has discovered that the hostname and Domain Connect template are supported. The callback should request a short-lived, signed authorization URL from your backend and then redirect the browser.
```tsx title="domain-settings.tsx"
import { OneClickDnsSetup } from "@/components/domain/one-click-dns-setup";
{
const response = await fetch(`/api/domains/${domain.id}/dns-authorization`, {
method: "POST",
});
const { authorizationUrl } = await response.json();
window.location.assign(authorizationUrl);
}}
onManualSetup={() => setSetupMode("manual")}
/>;
```
Credentials, DNS discovery, template support checks, request signing, authorization URL generation, callback validation, and post-return refresh belong on the server. Do not construct or sign Domain Connect requests in this component.
## Confirmed provider paths [#confirmed-provider-paths]
| Application provider | Customer DNS provider | Setup |
| -------------------- | --------------------- | ----------------------------------------------------------- |
| Vercel | Cloudflare | Domain Connect |
| Railway | Cloudflare | Domain Connect |
| Cloudflare for SaaS | Any | Manual unless your own Domain Connect template is onboarded |
| Render | Any | Manual records |
| Netlify | External DNS | Manual records |
Cloudflare is the authoritative DNS provider in the confirmed one-click paths above. Eligibility is per hostname and template, so the UI intentionally does not infer support from `domain.provider`.
Provider references: [Vercel Domain Connect](https://vercel.com/changelog/automated-dns-configuration-with-domain-connect), [Railway one-click domains](https://blog.railway.com/p/one-click-domains), [Cloudflare Domain Connect](https://developers.cloudflare.com/dns/reference/domain-connect/), [Render custom domains](https://render.com/docs/custom-domains), and [Netlify external DNS](https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns/).
# DNS records (/docs/concepts/dns-records)
Every `DnsRecord` has a `type`, full `name`, `value`, `purpose`, `required` flag, and normalized `status`. Identical records are deduplicated; multiple valid verification methods remain available.
```ts title="render-records.ts"
for (const record of domain.records.filter((item) => item.required)) {
console.log(record.type, record.name, record.value, record.purpose, record.status);
}
```
Names are full hostnames where the provider supplies enough context. Your UI may additionally explain registrar shorthand, but it should not replace the authoritative value stored by your application.
Read next: [Display DNS records correctly](../guides/display-dns-records.mdx).
# Domain lifecycle (/docs/concepts/domain-lifecycle)
The lifecycle is `add` → configure returned DNS → `verify` when supported → `refresh` or `waitUntilActive` → `remove`.
`add` is safely idempotent when the adapter can prove the hostname already belongs to the configured project, zone, environment, or service. It throws `DOMAIN_CONFLICT` rather than moving a hostname owned elsewhere. `remove` succeeds when the hostname is already absent and never removes a resource from another configured scope.
Your application owns tenant authorization and persistence. The provider owns live domain state.
Read next: [Status model](./status-model.mdx) and [add and remove domains](../guides/add-remove-domains.mdx).
# Error handling (/docs/concepts/error-handling)
```ts title="handle-domain-error.ts"
import { DomainSdkError } from "@opencoredev/domain-sdk";
try {
await domains.add("app.customer.com");
} catch (error) {
if (error instanceof DomainSdkError) {
console.error(error.code, error.retryable, error.retryAfter);
}
}
```
Errors normalize authentication, permission, conflict, not-found, rate-limit, provider availability, timeout, and cancellation states. Response details are bounded and recursively redact credential-shaped fields.
Read next: [`DomainSdkError`](../api-reference/domain-sdk-error.mdx).
# Custom domain architecture concepts (/docs/concepts)
A safe custom-domain system separates tenant authorization, provider state, DNS routing, hostname ownership, and certificate issuance. These concepts explain the boundaries Domain SDK preserves and the state your application needs to represent.
# Managed certificates (/docs/concepts/managed-certificates)
`domain.certificate.status` is `pending`, `active`, `expiring`, `failed`, or `unknown`. Domain SDK does not issue certificates itself.
Cloudflare and Railway expose explicit certificate state. Netlify exposes the site's certificate and its covered domains, which lets the adapter confirm when an alias is active. Vercel and Render confirm usable or verified domain configuration without exposing complete certificate metadata, so those adapters avoid inventing issuer or expiration details.
Read next: [Troubleshoot certificate issuance](../guides/troubleshoot-certificates.mdx).
# Ownership verification (/docs/concepts/ownership-verification)
Ownership records have `purpose: "ownership"`. They are separate from routing and certificate validation even when every instruction happens to use TXT or CNAME records.
Vercel exposes project-domain verification challenges. Cloudflare exposes `ownership_verification` and may also offer HTTP validation. Railway requires a TXT verification token in addition to routing records. Render verifies ordinary domains from routing and returns extra ownership and certificate records for wildcards. Netlify does not expose per-alias ownership challenges, so an issued certificate covering the alias is the strongest state available through its site API. Treat provider verification as authoritative; DNS resolution alone does not establish tenant ownership.
Read next: [Tenant authorization and domain security](../guides/tenant-security.mdx).
# Platform providers (/docs/concepts/platform-providers)
A provider adapter translates one platform's API into the `DomainProvider` contract. Changing adapters keeps your application lifecycle stable, but it does not erase platform limitations or migrate live traffic.
Vercel scopes domains to a project. Cloudflare uses Custom Hostnames inside a SaaS zone. Railway scopes custom domains to a project, environment, and service. Render scopes domains to a web service or static site. Netlify manages additional customer domains as site aliases while preserving the primary domain. The memory provider is isolated, deterministic test infrastructure.
Read next: [Provider overview](../providers/index.mdx).
# Provider capabilities (/docs/concepts/provider-capabilities)
```ts title="verify-if-supported.ts"
if (domains.capabilities.explicitVerification) {
await domains.verify("app.customer.com");
}
```
Basic `add`, `get`, `refresh`, and `remove` do not require capability checks. Calling an unsupported optional operation throws `UNSUPPORTED_OPERATION` with the provider ID.
`createSubdomainClient` exposes the same wildcard check as `supportsWildcard`. Its `provisionWildcard` methods fail with `UNSUPPORTED_OPERATION` before calling a provider that cannot attach wildcard hostnames.
Read next: [Provider overview](../providers/index.mdx).
# Security model (/docs/concepts/security-model)
Domain SDK accepts provider credentials only in adapter configuration and sends them only to fixed HTTPS provider API origins. It does not log tokens, disable TLS, follow provider-returned URLs, execute commands, store domains, or emit telemetry.
Always authorize hostname operations by tenant, normalize before uniqueness checks, rate-limit mutations and verification, and remove provider configuration when a tenant deletes a domain. Protect abandoned hostnames from reassignment and never trust DNS resolution as proof of ownership.
Read next: [Production credential setup](../guides/production-credentials.mdx) and [tenant security](../guides/tenant-security.mdx).
# Status model (/docs/concepts/status-model)
| Status | Meaning |
| ---------------------- | ---------------------------------------------------------- |
| `pending` | Work has started but the provider gives no narrower state. |
| `pending_dns` | Required routing DNS is incomplete. |
| `pending_verification` | Ownership verification is incomplete. |
| `pending_certificate` | DNS and ownership are ready but TLS is not. |
| `active` | The provider reports the hostname ready. |
| `misconfigured` | DNS conflicts with provider requirements. |
| `failed` | A permanent provider-side step failed. |
| `unknown` | The provider does not expose enough detail. |
Do not infer activation from a CNAME lookup. Enable traffic only for `active`.
Read next: [`DomainStatus`](../api-reference/domain-status.mdx).
# Add and remove customer domains (/docs/guides/add-remove-domains)
```ts title="domain-lifecycle.ts"
const domain = await domains.add("app.customer.com");
await domains.remove(domain.hostname);
```
Repeated adds return current state only when the adapter can prove the domain belongs to the configured project, zone, environment, or service. Another owner throws `DOMAIN_CONFLICT`. Repeated removal succeeds when already absent and does not cross provider scope.
Read next: [Error handling](../concepts/error-handling.mdx).
# Handle apex domains and subdomains (/docs/guides/apex-and-subdomains)
Domain SDK validates full hostnames but does not guess registrable apex domains with a two-label rule. Use `domains.capabilities.apexDomains` for provider support.
Railway apex instructions use ALIAS/ANAME or CNAME flattening. Vercel supports apex configuration. Cloudflare for SaaS exposes CNAME routing and reports apex support unavailable. Render accepts wildcard domains; other current adapters reject them.
For tenant hostnames under a domain you own, use [`createSubdomainClient`](./managed-subdomains.mdx) to constrain direct-child labels and choose wildcard or individual provisioning.
Read next: [Provider overview](../providers/index.mdx).
# Build a custom-domain settings page (/docs/guides/custom-domain-settings-page)
The secure flow is: authorize the tenant, add the normalized hostname on the server, store normalized state, render DNS records, refresh in a background job, and enable traffic only after `active`.
```ts title="app/api/domains/route.ts"
import { domains } from "@/server/domains";
import { requireTenant } from "@/server/auth";
import { database } from "@/server/database";
export async function POST(request: Request) {
const tenant = await requireTenant(request);
const { hostname } = (await request.json()) as { hostname: string };
await tenant.assertCanManageDomains();
const domain = await domains.add(hostname);
await database.customDomains.upsert({
tenantId: tenant.id,
hostname: domain.hostname,
provider: domain.provider,
providerDomainId: domain.id,
status: domain.status,
});
return Response.json(domain);
}
```
Suggested framework-neutral storage:
```text title="Database model"
CustomDomain
- id
- tenantId
- hostname (unique after normalization)
- provider
- providerDomainId
- status
- createdAt
- updatedAt
```
Never let a tenant query or remove another tenant's hostname. Rate-limit add and verify endpoints, archive or delete local state after provider removal, and defend abandoned hostnames from takeover.
Read next: [Store domain state](./store-domain-state.mdx) and [tenant security](./tenant-security.mdx).
# Display DNS records correctly (/docs/guides/display-dns-records)
Display type, full name, value, purpose, required status, current status, and description. Preserve multiple verification methods. Offer a copy button per value, but do not convert full names to `@` in stored data.
```ts title="dns-table.ts"
const rows = domain.records.map(({ type, name, value, purpose, required, status }) => ({
type,
name,
value,
purpose,
required,
status,
}));
```
Explain that registrar UIs may automatically append the zone and that proxying can interfere with provider checks.
Read next: [DNS records](../concepts/dns-records.mdx).
# Custom domain implementation guides (/docs/guides)
These implementation guides show how to turn Domain SDK's provider-independent API into a complete custom-domain workflow for a multi-tenant SaaS application. Start with the settings-page guide, then follow the focused guide for the part of the lifecycle you are building.
## Build the workflow [#build-the-workflow]
## Handle edge cases safely [#handle-edge-cases-safely]
# Give tenants managed subdomains (/docs/guides/managed-subdomains)
Use `createSubdomainClient` when tenants choose hostnames such as `acme.mydomain.com` under a parent domain controlled by your application. It accepts direct-child labels only, normalizes them, enforces the parent-domain boundary, and blocks labels you reserve.
```ts title="server/subdomains.ts"
import { createDomainClient, createSubdomainClient } from "@opencoredev/domain-sdk";
import { render } from "@opencoredev/domain-sdk/render";
const domains = createDomainClient({
provider: render({
apiKey: process.env.RENDER_API_KEY!,
serviceId: process.env.RENDER_SERVICE_ID!,
}),
});
export const subdomains = createSubdomainClient({
domainClient: domains,
baseDomain: "mydomain.com",
reservedLabels: ["www", "api", "admin", "mail"],
});
```
## Provision one wildcard [#provision-one-wildcard]
When `subdomains.supportsWildcard` is true, attach the wildcard once and configure every required record it returns. After the wildcard becomes active, creating a tenant does not require another platform-domain or DNS mutation.
```ts title="scripts/provision-tenant-domain.ts"
const wildcard = await subdomains.provisionWildcard();
// Show or apply every record where record.required is true.
console.log(wildcard.records);
await subdomains.waitUntilWildcardActive();
```
Render is currently the only adapter that exposes wildcard provisioning through Domain SDK. Provider-specific DNS and certificate requirements still apply.
## Claim a tenant label [#claim-a-tenant-label]
Generate the hostname inside the same database transaction that claims the unique label. Domain SDK remains stateless and does not decide which tenant owns it.
```ts title="app/api/tenant-subdomain/route.ts"
const label = "acme";
const hostname = subdomains.toHostname(label); // acme.mydomain.com
const normalizedLabel = subdomains.fromHostname(hostname);
await database.tenants.update({
id: tenant.id,
subdomainLabel: normalizedLabel, // unique
hostname,
});
```
Authorize the tenant, rate-limit changes, and route requests only after looking up the exact normalized hostname. Do not route an unknown hostname to a default tenant.
## Fall back to individual hostnames [#fall-back-to-individual-hostnames]
If wildcard provisioning is unavailable, attach each tenant hostname separately. Configure the returned required DNS records in the parent zone you control, then wait for it to become active.
```ts
const domain = await subdomains.add("acme");
// Apply domain.records in your authoritative DNS provider.
const active = await subdomains.waitUntilActive("acme");
```
The SDK does not edit the parent DNS zone automatically. This keeps DNS credentials and destructive record ownership outside the normalized custom-domain lifecycle.
Read next: [Tenant authorization and domain security](./tenant-security.mdx).
# Poll until a domain is active (/docs/guides/poll-until-active)
```ts title="activate-domain.ts"
const controller = new AbortController();
const active = await domains.waitUntilActive("app.customer.com", {
timeoutMs: 5 * 60_000,
intervalMs: 5_000,
signal: controller.signal,
onStatus(domain) {
console.log(domain.status);
},
});
```
Polling checks immediately, never overlaps checks, defaults to five-second intervals and a five-minute timeout, honors longer `Retry-After` values, and stops on permanent errors. It throws `TIMEOUT` or `ABORTED`; it does not guarantee DNS propagation time.
Read next: [`waitUntilActive`](../api-reference/wait-until-active.mdx).
# Production credential setup (/docs/guides/production-credentials)
* **Vercel:** use a token with access to only the target team/project and set project plus optional team ID.
* **Cloudflare:** use a scoped API token with Custom Hostnames/SSL access to only the SaaS zone; configure and validate your fallback origin separately.
* **Railway:** use a project/workspace token limited to the target project, environment, and service.
Store secrets in your server's encrypted environment/secret manager. Rotate them after suspected exposure. Never return adapter options, request headers, or provider errors directly to a browser.
Read next: [Security model](../concepts/security-model.mdx).
# Store domain state in your database (/docs/guides/store-domain-state)
Store the normalized hostname as a unique value together with tenant ID, provider ID, provider-domain ID, cached status, and timestamps. Optionally cache records for UI display, but replace them on every refresh.
Do not use your cache to authorize a provider removal without first authorizing the tenant. Domain SDK itself remains stateless and requires no database.
Read next: [Status model](../concepts/status-model.mdx).
# Switch providers safely (/docs/guides/switch-providers)
Create a separately authorized migration workflow: add the hostname to the destination, complete destination ownership/certificate pre-validation when supported, schedule DNS cutover, monitor destination state, then remove the source only after validation and rollback windows.
Domain SDK keeps the application contract stable but does not synchronize two providers, automate customer DNS, or guarantee zero downtime.
Read next: [Platform providers](../concepts/platform-providers.mdx).
# Tenant authorization and domain security (/docs/guides/tenant-security)
Authorize every add, get, verify, refresh, and remove by tenant. Normalize before uniqueness checks. Rate-limit mutations. Remove provider configuration when a tenant disconnects a hostname. Do not reassign an abandoned hostname until provider ownership is safely re-established.
Never trust current DNS resolution as ownership proof, collect registrar passwords, or expose provider credentials. Provider verification is authoritative.
Read next: [Build a settings page](./custom-domain-settings-page.mdx).
# Test without external API calls (/docs/guides/test-without-network)
```ts title="domain-flow.test.ts"
const memory = memoryProvider({ latencyMs: 5, initialStatus: "pending_dns" });
const domains = createDomainClient({ provider: memory });
await domains.add("app.customer.com");
memory.transition("app.customer.com", "pending_certificate");
memory.activate("app.customer.com");
expect(memory.calls.map((call) => call.operation)).toContain("add");
```
Use `createFailingProvider(new DomainSdkError(...))` for application error paths. Normal package tests never require credentials or internet access.
Read next: [Testing provider](../providers/testing.mdx).
# Troubleshoot certificate issuance (/docs/guides/troubleshoot-certificates)
Confirm routing and ownership first, then render records with `purpose: "certificate"`. Check CAA/DNSSEC and proxy configuration when the provider reports validation errors. Cloudflare may return HTTP validation instead of DNS; the SDK preserves that as a message.
Use `domain.certificate.status` and issues rather than probing HTTPS yourself as the source of truth.
Read next: [Managed certificates](../concepts/managed-certificates.mdx).
# Troubleshoot pending DNS (/docs/guides/troubleshoot-dns)
1. Refresh the domain and render the newest required records.
2. Compare full names and values exactly, including TXT prefixes.
3. Check that both routing and ownership records exist; Railway requires both.
4. Disable incompatible CDN proxying where the provider requires direct DNS visibility.
5. Inspect `domain.issues` and provider-specific limitations.
Do not delete and recreate repeatedly or promise a propagation deadline.
Read next: [Provider pages](../providers/index.mdx).
# Cloudflare for SaaS custom domains (/docs/providers/cloudflare)
## Scope and credentials [#scope-and-credentials]
The adapter creates, retrieves, lists, and deletes Custom Hostnames in one zone. It does not create zone DNS records, configure a fallback origin, or manage customer DNS. Set `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ZONE_ID`, and `CLOUDFLARE_CNAME_TARGET`. Use a scoped API token with Custom Hostnames/SSL permissions for only the SaaS zone.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { cloudflareSaaS } from "@opencoredev/domain-sdk/cloudflare";
export const domains = createDomainClient({
provider: cloudflareSaaS({
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
zoneId: process.env.CLOUDFLARE_ZONE_ID!,
cnameTarget: process.env.CLOUDFLARE_CNAME_TARGET!,
}),
});
const domain = await domains.add("app.customer.com");
```
## DNS, verification, and certificates [#dns-verification-and-certificates]
The configured CNAME target becomes the routing record. `ownership_verification` becomes ownership DNS; `ssl.validation_records` become certificate DNS. HTTP validation is preserved as a safe message because it is not a DNS record. A hostname becomes active only when both custom-hostname and SSL states are active.
## Errors and limitations [#errors-and-limitations]
Structured Cloudflare error arrays are sanitized and normalized. Configure a valid fallback/default origin in Cloudflare before adding hostnames; an invalid origin is platform configuration, not customer DNS. v0.1 does not expose wildcards, generic DNS mutation, or apex routing.
Official references: [Custom Hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/), [create hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/), and [hostname validation](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/).
Read next: [Ownership verification](../concepts/ownership-verification.mdx).
# Custom domain provider comparison (/docs/providers)
Choose the provider where your application already serves traffic. Domain SDK normalizes the lifecycle, but credentials, resource scope, DNS requirements, and platform limits remain provider-specific.
## Compatibility [#compatibility]
| Capability | Vercel | Cloudflare SaaS | Railway | Render | Netlify |
| ---------------------- | :----: | :-------------: | :-----: | :--------------: | :-----: |
| Add domain | ✓ | ✓ | ✓ | ✓ | ✓ |
| Get status | ✓ | ✓ | ✓ | ✓ | ✓ |
| List domains | ✓ | ✓ | ✓ | ✓ | ✓ |
| Explicit verification | ✓ | — | — | ✓ | — |
| Remove domain | ✓ | ✓ | ✓ | ✓ | ✓ |
| Managed certificates | ✓ | ✓ | ✓ | ✓ | ✓ |
| Apex domains | ✓ | — | ✓ | ✓ | ✓ |
| Wildcard domains | — | — | — | ✓ | — |
| Ownership records | ✓ | ✓ | ✓ | wildcard domains | — |
| Routing records | ✓ | ✓ | ✓ | ✓ | ✓ |
| Automatic customer DNS | — | — | — | — | — |
Cloudflare for SaaS requires a CNAME target, so the current adapter does not claim apex support. Render is the only adapter that accepts wildcard hostnames. Netlify manages aliases without replacing the site's primary domain. No adapter edits customer DNS automatically.
Provider credentials belong to your SaaS account, never to the customer. The memory adapter is available separately for [testing](./testing.mdx), not as a production provider.
# Netlify custom domains (/docs/providers/netlify)
## Scope and credentials [#scope-and-credentials]
The adapter manages `domain_aliases` on one Netlify site. It never replaces the site's primary `custom_domain`. Set `NETLIFY_ACCESS_TOKEN` and `NETLIFY_SITE_ID`; use a token that can read and update only the intended site where possible.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { netlify } from "@opencoredev/domain-sdk/netlify";
export const domains = createDomainClient({
provider: netlify({
accessToken: process.env.NETLIFY_ACCESS_TOKEN!,
siteId: process.env.NETLIFY_SITE_ID!,
}),
});
const domain = await domains.add("app.customer.com");
```
## DNS and certificates [#dns-and-certificates]
Subdomains return a CNAME pointing to the site's `.netlify.app` hostname. Apex domains return an ALIAS to `apex-loadbalancer.netlify.com`; when the customer's DNS provider cannot create ALIAS, ANAME, or flattened-CNAME records, use an A record to `75.2.60.5` instead.
Netlify does not expose independent DNS-verification state for each alias. The adapter treats an alias covered by the site's issued TLS certificate as verified and active. Until then, it remains `pending_dns`. Netlify manages certificate issuance, so the adapter does not call the certificate provisioning endpoint.
## Mutation and alias limits [#mutation-and-alias-limits]
Netlify updates aliases as one array. Each add or remove reads the current site, preserves unrelated aliases and the primary domain, then writes the updated alias list. Serialize domain mutations for the same site to avoid lost updates from concurrent read-modify-write requests.
Netlify recommends no more than 50 aliases per site. The adapter does not impose a lower limit than the API, but you should use multiple sites if your product regularly exceeds that recommendation.
Official references: [Netlify API](https://open-api.netlify.com/), [domain aliases](https://docs.netlify.com/manage/domains/configure-domains/add-a-domain-alias/), and [external DNS](https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns/).
Read next: [Store domain state](../guides/store-domain-state.mdx).
# Railway custom domains (/docs/providers/railway)
## Scope and credentials [#scope-and-credentials]
The adapter checks availability, creates, retrieves, lists, and deletes Railway custom domains. It does not deploy services or edit customer DNS. Set `RAILWAY_TOKEN`, `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_ID`, and `RAILWAY_SERVICE_ID`. Use a project/workspace token with access only to the target resources.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { railway } from "@opencoredev/domain-sdk/railway";
export const domains = createDomainClient({
provider: railway({
token: process.env.RAILWAY_TOKEN!,
projectId: process.env.RAILWAY_PROJECT_ID!,
environmentId: process.env.RAILWAY_ENVIRONMENT_ID!,
serviceId: process.env.RAILWAY_SERVICE_ID!,
}),
});
const domain = await domains.add("app.customer.com");
```
## DNS, verification, and certificates [#dns-verification-and-certificates]
`status.dnsRecords` becomes routing DNS. `status.verificationToken` becomes the required `_railway-verify` TXT record. Both are required; valid routing alone never becomes active. Certificate `ISSUED` confirms readiness. Root domains use an ALIAS/ANAME or CNAME-flattening instruction because Railway does not publish a static A record.
## Errors and limitations [#errors-and-limitations]
GraphQL errors are detected even with HTTP 200. Unavailable domains map to `DOMAIN_CONFLICT`. The current public API has no explicit verification mutation, so refresh state with `refresh()`. Wildcards remain disabled in v0.1.
Official references: [Public API](https://docs.railway.com/integrations/api), [manage domains](https://docs.railway.com/integrations/api/manage-domains), and [working with domains](https://docs.railway.com/networking/domains/working-with-domains).
Read next: [Poll until active](../guides/poll-until-active.mdx).
# Render custom domains (/docs/providers/render)
## Scope and credentials [#scope-and-credentials]
The adapter creates, retrieves, lists, verifies, and removes custom domains for one Render service. Set `RENDER_API_KEY` and `RENDER_SERVICE_ID`. The service must be a web service or static site, and the API key must have access to it.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { render } from "@opencoredev/domain-sdk/render";
export const domains = createDomainClient({
provider: render({
apiKey: process.env.RENDER_API_KEY!,
serviceId: process.env.RENDER_SERVICE_ID!,
}),
});
const domain = await domains.add("app.customer.com");
```
## DNS, verification, and certificates [#dns-verification-and-certificates]
Subdomains return a CNAME pointing to the service's `onrender.com` hostname. Apex domains return Render's `216.24.57.1` load-balancer address and explain the preferred ALIAS, ANAME, or flattened-CNAME alternative. Wildcards also return Render's certificate and hostname-validation CNAME records.
Call `verify()` after the customer configures DNS. Render also verifies new domains automatically, so polling with `waitUntilActive()` remains useful. A verified domain becomes active; Render manages its TLS certificate but does not expose certificate metadata through the custom-domain response.
## Errors and limitations [#errors-and-limitations]
Adding a root or `www` hostname can cause Render to create its companion hostname automatically. The adapter returns the hostname you requested, while `list()` includes every domain Render created. Domain limits and charges depend on the Render workspace plan.
Official references: [Custom Domain API](https://api-docs.render.com/reference/create-custom-domain), [custom domains](https://render.com/docs/custom-domains), and [external DNS](https://render.com/docs/configure-other-dns).
Read next: [Poll until active](../guides/poll-until-active.mdx).
# Testing provider (/docs/providers/testing)
The memory provider supports add, get, list, verify, remove, manual transitions, latency, failures, configurable records/certificates, abort signals, and call inspection. Each instance is isolated.
```ts title="domains.test.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { memoryProvider } from "@opencoredev/domain-sdk/testing";
const memory = memoryProvider();
const domains = createDomainClient({ provider: memory });
await domains.add("app.customer.com");
memory.activate("app.customer.com");
expect((await domains.refresh("app.customer.com")).status).toBe("active");
```
`createMockDomain`, `createMockDnsRecord`, and `createFailingProvider` are also available only from the testing subpath.
Read next: [Test without external API calls](../guides/test-without-network.mdx).
# Vercel custom domains (/docs/providers/vercel)
## Scope and credentials [#scope-and-credentials]
The adapter adds, retrieves, lists, verifies, and removes domains for one Vercel project. It does not deploy applications, edit customer DNS, register domains, or move a domain from another project. Set `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and optional `VERCEL_TEAM_ID`. Give the token only the project/team access required for domain management.
```ts title="domains.ts"
import { createDomainClient } from "@opencoredev/domain-sdk";
import { vercel } from "@opencoredev/domain-sdk/vercel";
export const domains = createDomainClient({
provider: vercel({
token: process.env.VERCEL_TOKEN!,
projectId: process.env.VERCEL_PROJECT_ID!,
teamId: process.env.VERCEL_TEAM_ID,
}),
});
const domain = await domains.add("app.customer.com");
```
## DNS, verification, and certificates [#dns-verification-and-certificates]
Routing records come from Vercel's domain-configuration recommendations; ownership challenges become `ownership` records. `verify()` calls the project-domain verification endpoint. The adapter reports active configuration without inventing certificate issuer or expiration metadata unavailable from the project-domain API.
## Errors and limitations [#errors-and-limitations]
Expired tokens map to `AUTHENTICATION_FAILED`; inaccessible resources map to `PERMISSION_DENIED`; another project/account maps to `DOMAIN_CONFLICT`; rate limits preserve `retryAfter`. Duplicate adds return current state only when `projectId` matches. Wildcards are not supported in v0.1.
Official references: [project domains](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project), [verification](https://vercel.com/docs/rest-api/projects/verify-project-domain), and [domain configuration](https://vercel.com/docs/rest-api/domains/get-a-domain-s-configuration).
Read next: [Troubleshoot pending DNS](../guides/troubleshoot-dns.mdx).
# Changelog (/docs/project/changelog)
## 0.1.0 [#010]
* Added the core client, normalized model, hostname validation, errors, polling, and logging contract.
* Added Vercel, Cloudflare for SaaS, Railway, Render, Netlify, and isolated memory providers.
* Added fixture-backed adapter tests and complete documentation.
* Render supports wildcard domains. Automatic customer DNS configuration remains unsupported.
Read next: [Introduction](../index.mdx).
# Contributing (/docs/project/contributing)
## Set up the workspace [#set-up-the-workspace]
```bash title="Terminal"
bun install
bun run dev
```
The repository uses Bun 1.3.14 and Turborepo. The docs run at `http://domain-sdk.localhost:1355` through the sudo-free Portless proxy.
## Work in the right package [#work-in-the-right-package]
* `packages/sdk` contains the public client, normalized types, adapters, and tests.
* `apps/fumadocs` contains the docs and landing page.
* `packages/config` contains shared TypeScript and lint configuration.
Keep changes focused. Preserve existing provider boundaries and do not add placeholder adapters, speculative options, or network-dependent tests.
## Test provider changes [#test-provider-changes]
Provider tests belong in `packages/sdk/test/providers`. Use an injected `fetch` fixture for every provider request; tests must not require credentials or internet access. Cover request paths, response normalization, idempotent add/remove behavior, malformed responses, and normalized errors.
```bash title="Terminal"
cd packages/sdk
bun test
bun run check-types
bun run lint
bun run build
```
## Test documentation changes [#test-documentation-changes]
Check generated MDX types and the production build before opening a pull request.
```bash title="Terminal"
cd apps/fumadocs
bun run check-types
bun run lint
bun run build
```
## Run the full repository checks [#run-the-full-repository-checks]
```bash title="Terminal"
bun run test
bun run typecheck
bun run lint
bun run build
```
Open a pull request that explains the behavior change, provider API assumptions, and validation you ran. Include screenshots for visible docs or landing-page changes.
# Domain SDK project (/docs/project)
Domain SDK is an MIT-licensed open-source TypeScript project. Use the changelog to review user-visible releases, or follow the contribution guide to run the workspace and propose a focused change.