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

React

Headless hooks that run the custom-domain lifecycle over a transport your server owns, and the shadcn registry that renders them in your design system.

@domainkit/react runs the whole custom-domain lifecycle over a transport your server owns, and paints none of it. The package supplies state, copy, and accessibility; your application supplies the markup. Use it to add custom-domain setup to a React settings page. Provider credentials never reach the browser: the transport calls the routes you mounted from domainkit/server.

npm install @domainkit/react domainkit effect react react-dom
pnpm add @domainkit/react domainkit effect react react-dom
yarn add @domainkit/react domainkit effect react react-dom
bun add @domainkit/react domainkit effect react react-dom
nub add @domainkit/react domainkit effect react react-dom
aube add @domainkit/react domainkit effect react react-dom

React 19 is required. Install domainkit and @domainkit/react at the same release version.

One hook

/** 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>
  );
}

Domain.useFlow connects a provider, plans the DNS changes, takes the customer’s approval or refusal, applies the plan, observes the records, and cleans them up against the apply receipt. It returns the four controllers, the plan the rows report, the latest readiness, the capability groups the transport declares, and a FlowState your own surface reads.

Writing Transport.fromFetch("/api/domainkit") inline in JSX is fine: DomainKit.Root keeps the transport’s identity for the whole mount, so the controllers do not restart on every render.

The styled path

The registry vends the flow as shadcn components written against your own ui/button, ui/dialog, ui/table, and ui/badge, on the Base UI idiom.

npx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
pnpm dlx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
yarn dlx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
bunx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
nubx shadcn@latest add https://domain-kit.dev/r/domain-flow.json
aube dlx shadcn@latest add https://domain-kit.dev/r/domain-flow.json

The files land in components/domainkit/ and are yours after the copy. A project without a shadcn kit composes the hooks directly.

Two ways in

Level You write Reach for it when
The block <DomainFlow domain requirements /> The product needs a working screen today
The hooks useFlow or the four controllers The lifecycle belongs inside a screen you already have

Composition covers the surface you write over the flow, controllers the hooks behind it, and words the message catalog every string comes from.

Events

/** Finished steps arrive as events, for your notifications, analytics, and audit trail. */
export function DomainSettingsWithEvents() {
  return (
    <DomainKit.Root
      onEvent={(event) => {
        if (event._tag === "Applied") track("dns.applied", { receiptId: event.receipt.id });
      }}
      transport={transport}
    >
      <DomainSetup />
    </DomainKit.Root>
  );
}

onEvent fires when a customer finishes something: Connected, Applied, Cleaned, Declined, Detached, Disconnected, and Failed. A listener that throws cannot change the outcome of an operation that already completed.

Keeping the UI honest

Pass revision and bump it after a change the UI did not make, such as a webhook that disconnected a provider. Every mounted domain re-inspects.

Next.js

The package entry carries "use client". A transport is an object of functions, so build it inside a client module rather than passing one down from a server component.

The member view

A customer who may read a domain but not change it gets readOnly. Every command that would start a write refuses to run, and the flag rides on flow.state.readOnly, so your surface can say who may connect instead of rendering an empty row.

/**
 * A customer who may read the domain but not change it. Every command that would start a write
 * refuses to run, and `flow.state.readOnly` is what your surface reads to say who may connect.
 */
function MemberRow({ domain }: { readonly domain: string }) {
  const flow = Domain.useFlow({ domain, requirements });
  const host = Connect.hostProvider(flow.connection);
  if (!flow.state.readOnly || host === null) return null;
  return <p>An administrator can connect {host.name}</p>;
}

export function MemberView() {
  return (
    <DomainKit.Root readOnly transport={transport}>
      <MemberRow domain="app.example.com" />
    </DomainKit.Root>
  );
}
/** One domain among several, when the rest of the page is writable. */
function DomainRow({ domain, mine }: { readonly domain: string; readonly mine: boolean }) {
  const flow = Domain.useFlow({ domain, readOnly: !mine, requirements });
  return <p>{flow.state.connection}</p>;
}

export function DomainList() {
  return (
    <DomainKit.Root transport={transport}>
      {domains.map((domain) => (
        <DomainRow domain={domain.name} key={domain.name} mine={domain.mine} />
      ))}
    </DomainKit.Root>
  );
}

Capability gating is the other half: a group the transport never declares is absent from flow.capabilities. readOnly covers the authorization a transport cannot express, such as a member of an organisation who reaches the same routes and would get a 403 from any write.

Observation and verification retries stay available, because checking DNS reads the world rather than changing the domain. Provisioning and cleanup retries are refused. Connection retries re-inspect instead of resending a failed write.

Verification without a connection

The flow observes the requirements it was given, so a domain with no provider attached still reports which records are in place instead of failing on the missing receipt.

/**
 * Observation stays available, because checking DNS reads the world rather than changing the
 * domain, and it does not wait for a connection: the controller observes the requirements it was
 * given, so a domain with no provider attached still reports which records are in place.
 */
export function UnattachedStatus({ domain }: { readonly domain: string }) {
  const controller = Verify.useController({ domain, requirements });
  return <p>{controller.readiness?.overall ?? "checking"}</p>;
}

Where an interactive provider returns

/**
 * An interactive provider returns the customer to the page they started from, read when they
 * connect rather than when the flow renders. Name a different destination, or pass `null` to leave
 * the server's `defaultReturnTo` in charge.
 */
function DomainSetupWithReturn() {
  const flow = Domain.useFlow({
    domain: "app.example.com",
    requirements,
    returnTo: "/settings/domains?connected=1",
  });
  return <p>{flow.state.connection}</p>;
}

export function DomainSettingsWithReturn() {
  return (
    <DomainKit.Root transport={transport}>
      <DomainSetupWithReturn />
    </DomainKit.Root>
  );
}

returnTo defaults to the page the customer started from, read when they connect rather than when the flow renders. Pass null to send none and leave the server’s defaultReturnTo in charge.

Next

Last updated on

Was this page helpful?