Review before write
The plan is additive. DomainKit creates missing records, leaves exact matches alone, and reports incompatible state as a conflict. It never updates or deletes a record it did not create.
How plans work →Open source · MIT · TypeScript
A TypeScript library that runs in your app. Your customer connects Cloudflare or Vercel once and approves the exact records; DomainKit applies them and verifies they resolve.
bun add domainkit effectexport 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]),
);/**
* 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>
);
}import { Effect } from "effect";
import { DnsRecord, Provision, Verify } from "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",
}),
];
export const program = Effect.gen(function* () {
const plan = yield* Provision.plan({ domain: "app.example.com", requirements });
// Create the CNAME, no-op the TXT already in place, conflict on anything incompatible,
// and a digest over all of it for the customer to approve.
const approval = yield* Provision.approve(plan);
const receipt = yield* Provision.apply(approval);
// One outcome per operation. A write that fails after an earlier one lands is a `partial`
// receipt, not an exception, and retrying any step replays its result.
const readiness = yield* Verify.observe({ domain: "app.example.com" });
return { receipt, ready: readiness.overall === "ready" };
});Live demo. Test provider and example domain. Nothing here writes to a real zone.
[ 01 · The problem ]
To use their own domain, your customer has to open a registrar they barely know and copy three or four fields per record. One screen wants @ for the host. Another wants the bare domain. Some add the domain name for you, and some want a dot on the end. Then they click Verify and wait.
Webflow, Ghost, Framer, Resend, and Postmark all ship pages like this, several with a variant for each registrar. They are good pages for a hard problem. They still hand the customer the last step.
"Some of your customers will likely struggle to configure their DNS correctly, and thus you will need to provide tech support to them."
Netlify support guide, 2021
Today
Host: send Value: …
With DomainKit
k1._domainkey_verify[ 02 · How it works ]
Your customer signs in to Cloudflare or Vercel from your app, or pastes a token. DomainKit finds the zone that serves the domain.
They see every record before anything changes. A missing record says Will add. A record that already matches says Already set. Anything in the way says Conflict, and DomainKit leaves it alone.
They approve the exact plan. DomainKit writes what they approved and keeps a receipt of each write.
DomainKit checks the provider and public DNS and shows where each record stands. If something is still pending, it says when it will look again.
k1._domainkey.mail.northwind.appk1.dkim.example.net_verify.mail.northwind.appverify=8f2c…track.mail.northwind.appPoints to old.tracker.example. DomainKit will not change it.Writes go one record at a time. If one fails after another lands, the receipt says how far it got. Nothing is rolled back.
[ 03 · The lifecycle ]
Each step is one call. The plan is a read, so nothing changes until the customer says yes.
Provision.planProvision.approveProvision.applyVerify.observeEvery failure is one DomainKit.Error with a tagged reason, so you can match on it. Failure reasons →
import { Effect } from "effect";
import { DnsRecord, Provision, Verify } from "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",
}),
];
export const program = Effect.gen(function* () {
const plan = yield* Provision.plan({ domain: "app.example.com", requirements });
// Create the CNAME, no-op the TXT already in place, conflict on anything incompatible,
// and a digest over all of it for the customer to approve.
const approval = yield* Provision.approve(plan);
const receipt = yield* Provision.apply(approval);
// One outcome per operation. A write that fails after an earlier one lands is a `partial`
// receipt, not an exception, and retrying any step replays its result.
const readiness = yield* Verify.observe({ domain: "app.example.com" });
return { receipt, ready: readiness.overall === "ready" };
});[ 04 · Safety model ]
The plan is additive. DomainKit creates missing records, leaves exact matches alone, and reports incompatible state as a conflict. It never updates or deletes a record it did not create.
How plans work →Approval is bound to the plan's digest. Before it writes, apply plans the zone again. If the zone changed since the customer looked, apply stops with Stale.
Your key seals the credential. Your database holds the rows. Your session says who is calling. No credential reaches the browser, and there is no hosted service holding your customers' tokens.
Custody and storage →For each record, DomainKit stores what the provider returned and what each public resolver saw, then schedules the next check.
How verification works →Cleanup is a separate approval. Removing records is its own plan, bound to an apply receipt. A record someone edited by hand is left alone.Cleanup →
[ 05 · In production ]
Samva is an email API. When a customer adds a sending domain, Samva uses DomainKit to add the SPF and verification TXT records, the MX records, and the DKIM and tracking CNAME records to the customer's Cloudflare or Vercel zone, after they approve the plan.
Onboarding gets difficult mainly because of DNS. Copying and pasting records is error-prone and it increases support cases. That's what we built DomainKit to solve.
[ 06 · Integrate ]
One route group with twenty-five endpoints, and an OpenAPI document with it. You write one service, Identity, that says who is calling and what they can reach. Storage and custody come from your own database and key.
/**
* 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]),
);One hook, Domain.useFlow, runs connect, plan, approve, apply, and verify. Render it yourself, or copy the styled block into your project and change anything.
/** 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.jsonDomainKit is Effect 4 underneath. Browser code can stay in Promises through Transport.toAsync.
[ 07 · Boundaries ]
Your customer's records stay at their own provider.
DomainKit writes DNS records. Certificates and routing stay with your host, such as Cloudflare for SaaS or Vercel.
Using DomainKit with Cloudflare for SaaS →It runs in your app, with your database and your key. There is no SLA and no support contract.
Cloudflare and Vercel are built in. For any other provider, your app shows the records to add by hand, or you write an adapter. Entri lists about 60 providers, and that matters if your customers are mostly elsewhere.
DomainKit vs Entri →They connect Cloudflare or Vercel, then see a table of the exact records: which ones will be added, which are already set, and which conflict. One button adds the ones they approved. After that they see each record's status until it resolves. They never type a record value.
No. It writes DNS records and checks that they resolve. Certificates and traffic belong to your host, such as Cloudflare for SaaS, Vercel, or Approximated. DomainKit writes the CNAME and validation records those services ask your customer to add.
DomainKit can't write there today. Your records are plain values, so your app can show them as a table to add by hand, the way it does now. You can also write a provider adapter for another DNS provider. Write a provider →
Records that already match are left alone. Records in the way are reported as conflicts, and DomainKit does not overwrite them. Writes are not atomic. If one fails after another lands, the receipt records exactly which writes completed, and re-planning turns those into no-ops. Cleanup is a separate plan that removes only records the receipt proves DomainKit created and that still match.
In your database, sealed with your key. DomainKit defines the Custody and Storage interfaces and your app implements them. The browser never receives a credential.
The lifecycle is written in Effect 4, and the services you implement (Identity, Custody, Storage) are Effect services. You can mount the routes behind a plain fetch handler, and browser code can use Promises through the client transport. If your team doesn't use Effect anywhere, plan for a small learning step on the server.
Nothing. DomainKit is MIT-licensed open source with no per-domain fee and no paid plan. You run it in your own app, so you pay for your own hosting and database.
Entri is a hosted modal with about 60 providers. Domain Connect is an open protocol where each DNS provider onboards your template. DomainKit is a library in your app that writes through the customer's own Cloudflare or Vercel account. Each fits different teams. DomainKit vs Entri → DomainKit vs Domain Connect →
The Quickstart runs the whole lifecycle in memory, with no credentials.
bun add domainkit effect