SaaS custom domain setup, step by step
A code-first guide to SaaS custom domain setup. Declare the records, let the customer connect DNS, review and approve, apply, and verify.
A custom domain has three jobs. DNS points the domain at you, a hostname setup routes it, and TLS makes it safe. This guide covers the DNS job.
| Job | What it does | Who does it |
|---|---|---|
| DNS | Point the customer’s domain at your app and prove it’s theirs | DomainKit, and this guide |
| Hostname | Tell your host or proxy to serve that domain | Your host: Cloudflare for SaaS, Vercel, or a proxy |
| TLS | Issue and renew the certificate | Your host |
You’ll build the DNS job. When you’re done, a customer can connect their DNS account, see the exact records, approve them, and watch them verify, all inside your app.
You need Bun or Node 24, a TypeScript app, and an API that can identify the signed-in customer. The Quickstart runs the same lifecycle in memory first, if you want to see it before you wire it up.
1. Decide what the customer has to add
Most custom domains need a CNAME that points the hostname at you and a TXT record that proves ownership. If you accept apex domains, declare an A record instead, since most DNS hosts don’t allow a CNAME at the apex.
export const requirements = [
DnsRecord.cname({
name: "app.example.com",
target: "edge.acme.dev",
purpose: "Serve your site",
}),
DnsRecord.txt({
name: "_acme.app.example.com",
value: "acme-verify=7f3a",
purpose: "Prove ownership",
}),
];A record’s policy says what may share its name. A CNAME is exclusive by default, and everything else appends. The purpose is the sentence your customer reads next to the record.
2. Mount the routes in your API
DomainKit ships one route group with twenty-five endpoints. You write one service, Identity, that
reads your session and says who is calling. A request never names its own owner.
/**
* The one service you write. Verify a credential you issued and look the tenant up yourself: a
* request never names its own `ownerId`, and one you cannot attribute fails closed. The provider
* callback is the one route where something else names it, and that something is DomainKit's own
* record of the flow rather than the request; `CallbackAwareIdentity` below takes it.
*
* Read it from a cookie. `/callback/:provider` is a top-level navigation the provider sends the
* browser on, so only what the browser attaches by itself arrives with it; a header-only scheme
* fails every interactive connection at the last step.
*/
export const IdentityLive = Layer.succeed(Server.Identity)({
principal: (request) =>
Effect.gen(function* () {
const token = request.cookies.session;
const session = token === undefined ? null : yield* sessions.verify(token);
return session === null
? yield* Effect.fail(
new DomainKit.Error({
reason: new Reason.Unauthenticated({ message: "The request carries no session" }),
}),
)
: session;
}),
});export const Api = HttpApi.make("app").add(Server.group);
export const ApiLive = HttpApiBuilder.layer(Api).pipe(
Layer.provide(Server.layer(Api, { defaultReturnTo: "/settings/domains" })),
Layer.provide([DomainKitLive, IdentityLive]),
);Storage and custody come from your own database and key. The host integration guide covers both.
3. Let the customer connect their DNS
Register the providers you want to offer. With no options, each accepts API tokens. Add your OAuth client for Cloudflare or your marketplace integration for Vercel.
/**
* 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",
},
});Starting a connection returns one of three answers. It connected, it needs a redirect, or it needs the customer to pick a zone.
/**
* OAuth and marketplace installs answer `Redirect`. Send the customer to `authorizationUrl`; the
* provider drives the browser back to `/callback/:provider`, which calls `Connect.complete`.
*/
export const withOAuth = Effect.map(
Connect.start({
provider: "cloudflare",
method: Connect.Method.oauth({ returnTo: "/settings/domains" }),
domain,
}),
(started) => (started._tag === "Redirect" ? started.authorizationUrl : null),
);
export const finish = (continuationId: string, callbackUrl: string) =>
Connect.complete({ continuationId, callbackUrl });The grant belongs to the account, not the domain. When the same customer adds another domain, ask DomainKit which connection already reaches it and skip this step.
/**
* Before offering the provider list, ask which connection this owner already has for the domain.
* `Resolved` means the second domain needs no connect step at all.
*/
export const reuseExisting = Effect.gen(function* () {
const discovery = yield* Connect.discover(domain);
switch (discovery._tag) {
case "Resolved":
return yield* Connect.attach({
connectionId: discovery.connectionId,
domain,
target: discovery.target,
});
case "SelectionRequired":
return discovery.candidates.map(({ connectionId, target }) => ({
connectionId,
zone: target.zone,
label: target.label,
}));
case "NotFound":
// `host` names the registered provider whose nameservers serve the domain, or is `null`.
return { host: discovery.host?.provider ?? null, nameservers: discovery.nameservers };
}
});4. Show the plan
Planning reads the customer’s zone and returns the exact operations. It never writes.
// Reads the attached zone and returns the exact operations, never a write.
export const build = Provision.plan({ domain: "app.example.com", requirements });/** What a review screen needs: the writes, the conflicts, and whether apply can run at all. */
export const review = (plan: Plan.Model) => ({
writes: Plan.writes(plan),
conflicts: Plan.conflicts(plan),
applicable: Plan.isApplicable(plan),
digest: plan.digest,
expiresAt: plan.expiresAt,
instructions: Plan.renderInstructions(plan),
});Each operation is a Create, a Noop, or a Conflict. Render them as Will add, Already set, and
Conflict. If the customer’s provider isn’t one you support, skip to your own table of records to
add by hand.
5. Let the customer approve
Approval binds the customer’s yes to the plan’s digest. They can approve everything, or name the operations they accept. They can also decline, which ends that plan.
/** Approval binds consent to the digest. Without `operationIds` it covers every write. */
export const approveEverything = (plan: Plan.Model) => Provision.approve(plan);
/** Partial approval names the operations and admits that conflicts stay behind. */
export const approveSome = (plan: Plan.Model) =>
Provision.approve(plan, {
operationIds: Plan.writes(plan)
.slice(0, 1)
.map((operation) => operation.id),
allowPartial: true,
});/** Declining is terminal for that plan and leaves the domain free for a new one. */
export const decline = (plan: Plan.Model) =>
Provision.reject(plan, { reason: "Customer wants to keep the current CNAME" });6. Apply and keep the receipt
Apply plans the zone again before it writes. If the zone moved since the customer looked, it fails
with Stale and you build a new plan. Writes go one record at a time, and they are not atomic. A
write that fails after another lands gives you a partial receipt, and planning again turns the
landed records into no-ops.
/**
* Apply re-plans the zone first and fails `Stale` when it moved. Partial success is a receipt with
* `status: "partial"`, not a failure: re-planning turns the written records into no-ops.
*/
export const applyAndSummarise = Effect.gen(function* () {
const plan = yield* build;
const approval = yield* Provision.approve(plan);
const receipt = yield* Provision.apply(approval);
return {
complete: Receipt.isComplete(receipt),
written: Receipt.applied(receipt).length,
outcomes: receipt.outcomes.map((outcome) => outcome._tag),
};
});/** Every failure is one `DomainKit.Error`; the reason says what to do next. */
export const explain = applyAndSummarise.pipe(
Effect.catchTag("DomainKitError", (error) =>
Match.value(error.reason).pipe(
Match.tag("Conflict", ({ operations }) =>
Effect.succeed(`Fix ${operations.length} conflicting record(s) first`),
),
Match.tag("Stale", () => Effect.succeed("The zone moved; build a new plan")),
Match.tag("Expired", () => Effect.succeed("The plan aged out; build a new one")),
Match.orElse(() => Effect.succeed(error.message)),
),
),
);7. Verify that the records resolve
Observation reads the provider and a pool of public resolvers, stores the status of each record, and says when to look again. A worker can sleep until that time.
/**
* One call reads the provider through the attachment's session and the public pool through
* `Resolver`, stores readiness per requirement, and says when to look again.
*/
export const check = Effect.map(Verify.observe({ domain }), (readiness) => ({
ready: readiness.overall === "ready",
nextCheckAt: readiness.nextCheckAt,
pending: readiness.requirements
.filter((requirement) => requirement.status !== "satisfied")
.map((requirement) => `${requirement.record._tag} ${requirement.record.name}`),
}));/** Readiness carries its own schedule, so a worker sleeps until `nextCheckAt` instead of polling. */
export const dueAt = Effect.map(
Verify.latest(domain),
(readiness) => readiness?.nextCheckAt ?? null,
);If your host has its own step, such as certificate issuance, add it to the same readiness with host evidence.
/** Merge what only your app can see — an email identity, a certificate — without re-reading DNS. */
export const recordCertificate = Effect.gen(function* () {
const observedAt = yield* DateTime.now;
return yield* Verify.attachEvidence({
domain,
evidence: [
new Verify.HostEvidence({
source: "edge-certificate",
status: "pending",
label: "TLS certificate",
detail: "Issuance starts once the CNAME resolves",
observedAt,
}),
],
});
});8. Put a UI on it
One hook runs the flow. Render it yourself, or copy the styled block into your project.
/** Points at the routes you mounted from `domainkit/server`. No credential reaches the browser. */
const transport = Transport.fromFetch("/api/domainkit");
const requirements = [
DnsRecord.cname({
name: "app.example.com",
target: "edge.acme.dev",
purpose: "Serve your site",
}),
DnsRecord.txt({
name: "_acme.app.example.com",
value: "acme-verify=7f3a",
purpose: "Prove ownership",
}),
];/**
* One hook. `Domain.useFlow` connects a provider, plans the changes, takes the customer's approval
* or refusal, applies the plan, observes the records, and cleans them up against the receipt. It
* reports only the capability groups the transport declares, and your markup renders it.
*/
function DomainSetup() {
const flow = Domain.useFlow({ domain: "app.example.com", requirements });
const writes = flow.plan === null ? [] : Plan.writes(flow.plan);
return (
<section>
<p>{flow.state.connected ? `Connected to ${flow.state.provider}` : "Not connected"}</p>
{writes.length === 0 ? null : (
<button onClick={() => flow.provisioning.approve()} type="button">
Add {writes.length} records
</button>
)}
</section>
);
}
export function DomainSettings() {
return (
<DomainKit.Root transport={transport}>
<DomainSetup />
</DomainKit.Root>
);
}npx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
See the React docs and the components.
9. Test without touching a real zone
The testing entry point ships a fake provider and a recording transport, so your tests drive the real lifecycle.
/** A host test drives the real services; nothing stubs global `fetch`. */
export const plansTheSecondRecord = Effect.gen(function* () {
yield* Connect.start({
provider: fake.id,
method: Connect.Method.token("test-token"),
domain: "app.plans.example.com",
});
const plan = yield* Provision.plan({
domain: "app.plans.example.com",
requirements: [
DnsRecord.cname({ name: "app.plans.example.com", target: "edge.acme.dev" }),
DnsRecord.txt({ name: "_acme.plans.example.com", value: "acme-verify=7f3a" }),
],
});
return plan.operations.map((operation) => operation._tag); // ["Create", "Noop"]
}).pipe(Effect.provideService(Principal.Service, Testing.principal), Effect.provide(TestLive));10. Clean up when a domain leaves
Removal is its own plan, built from the apply receipt. A record that still matches becomes a
Delete. A record someone edited by hand is left alone.
/**
* Cleanup is planned from an apply receipt, never from requirements. Each applied record is read
* back by its provider record id: still an exact match becomes `Delete`, anything else `Conflict`.
*/
export const planFromReceipt = (receiptId: Receipt.ReceiptId) => Cleanup.plan({ receiptId });
/** Or from the domain, which uses its latest provisioning receipt. */
export const planLatest = Cleanup.plan({ domain: "app.example.com" });/** Cleanup has its own approval and its own receipt under the same attempt rules. */
export const remove = (receiptId: Receipt.ReceiptId) =>
Effect.gen(function* () {
const plan = yield* Cleanup.plan({ receiptId });
const approval = yield* Cleanup.approve(plan);
return yield* Cleanup.apply(approval);
});Before you go live
Identityreturns the signed-in customer and never trusts a value from the request body.- Only the roles you choose can connect a provider or apply a plan. Use
authorize. - Credentials are sealed with your key. Your
Custodyimplementation is tested. - The provider callback URL is registered and matches your mount prefix.
- You show a manual table of records for customers whose DNS you can’t write to.
- Hostname and TLS are handled by your host. See Cloudflare for SaaS and DomainKit.
FAQ
Does this issue TLS certificates?
No. Your host does. DomainKit writes the DNS records your host asks customers to add.
What about customers on other DNS providers?
Show them the records to add by hand. DomainKit builds the plan only for providers it can write to.
Can customers add many domains?
Yes. They connect once per account, and each further domain reuses that connection. Batches plan several domains together.
Do I need React?
No. The routes and the client transport work without it.