Skip to content

enskit

enskit is the React toolkit for ENSv2 development. It provides a fully typed Omnigraph API client (powered by urql and gql.tada), the OmnigraphProvider, and the useOmnigraphQuery hook for writing type-safe ENS queries with editor autocomplete, Relay-style pagination, and Omnigraph-specific cache directives.

This guide walks you from an empty directory to a working React component that renders an ENS Domain and a paginated list of its subdomains — the same flow as the DomainView in our example app.

If you already have a React + TypeScript app, skip ahead to Install enskit and enssdk.

Otherwise, the fastest way to get going is Vite:

Terminal window
npm create vite@latest my-ens-app -- --template react-ts
cd my-ens-app
npm install
Terminal window
npm install enskit@1.13.1 enssdk@1.13.1

3. Configure the gql.tada TypeScript plugin

Section titled “3. Configure the gql.tada TypeScript plugin”

gql.tada is what gives your graphql(...) query strings end-to-end type safety. It reads the Omnigraph schema from enssdk at typecheck time.

Add the plugin to tsconfig.json:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"plugins": [
{
"name": "gql.tada/ts-plugin",
"schema": "node_modules/enssdk/src/omnigraph/generated/schema.graphql",
"tadaOutputLocation": "./src/generated/graphql-env.d.ts"
}
]
},
"include": ["src"]
}

If you’re using VS Code, make sure your workspace is using the workspace TypeScript version so the plugin loads. Add this to .vscode/settings.json:

.vscode/settings.json
{
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true
}

OmnigraphProvider is what useOmnigraphQuery reads from. Construct an EnsNodeClient, extend it with the omnigraph module, and wrap your app:

src/App.tsx
import { OmnigraphProvider } from "enskit/react/omnigraph";
import { createEnsNodeClient } from "enssdk/core";
import { omnigraph } from "enssdk/omnigraph";
import { StrictMode } from "react";
import { DomainView } from "./DomainView";
// you may use a NameHash Hosted ENSNode instance
// learn more at https://ensnode.io/docs/integrate/hosted-instances
const ENSNODE_URL = import.meta.env.VITE_ENSNODE_URL!
// create and extend an EnsNodeClient with Omnigraph support
const client = createEnsNodeClient({ url: ENSNODE_URL }).extend(omnigraph);
export function App() {
return (
<StrictMode>
<OmnigraphProvider client={client}>
<h1>My ENS App</h1>
<DomainView />
</OmnigraphProvider>
</StrictMode>
);
}

Create src/DomainView.tsx. We’ll start with the simplest possible query — look up the eth Domain and render its owner and protocol version.

src/DomainView.tsx
import { graphql, useOmnigraphQuery } from "enskit/react/omnigraph";
import { asInterpretedName, beautifyInterpretedName } from "enssdk";
const DomainByNameQuery = graphql(`
query DomainByName($name: InterpretedName!) {
domain(by: { name: $name }) {
__typename
canonical { name }
owner { address }
}
}
`);
export function DomainView() {
const name = asInterpretedName("eth");
const [result] = useOmnigraphQuery({
query: DomainByNameQuery,
variables: { name },
});
const { data, fetching, error } = result;
if (!data && fetching) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data?.domain) return <p>No domain found.</p>;
const { domain } = data;
return (
<div>
<h2>
{domain.canonical
? beautifyInterpretedName(domain.canonical.name)
: "Unnamed Domain"}
</h2>
<p>Version: {domain.__typename}</p>
<p>
Owner: <code>{domain.owner?.address ?? "0x0"}</code>
</p>
</div>
);
}

A few things to notice:

  • graphql(...) parses your query at typecheck time. Hover over result.data and you’ll see it’s typed exactly to your selection set — try removing owner { address } from the query and watch the access below become a type error.
  • domain is a union of ENSv1Domain | ENSv2Domain (both implement the Domain interface). The Omnigraph unifies ENSv1 and ENSv2 behind the same query — __typename tells you which one you got.
  • canonical may be null for non-canonical names (e.g. Domains whose name cannot be inferred). Always guard the access; TypeScript will help you.

Expand the query to also fetch the Domain’s subdomains. subdomains is a Relay Connection, so the shape is { edges: [{ node }] }.

src/DomainView.tsx
const DomainByNameQuery = graphql(`
query DomainByName($name: InterpretedName!) {
domain(by: { name: $name }) {
__typename
canonical { name }
owner { address }
subdomains {
edges {
node {
canonical { name }
owner { address }
}
}
}
}
}
`);
export function DomainView() {
const name = asInterpretedName("eth");
const [result] = useOmnigraphQuery({
query: DomainByNameQuery,
variables: { name },
});
const { data, fetching, error } = result;
if (!data && fetching) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data?.domain) return <p>No domain found.</p>;
const { domain } = data;
return (
<div>
<h2>{domain.canonical ? beautifyInterpretedName(domain.canonical.name) : "Unnamed Domain"}</h2>
<p>Version: {domain.__typename}</p>
<p>Owner: <code>{domain.owner?.address ?? "0x0"}</code></p>
<h3>Subdomains</h3>
<ul>
{domain.subdomains?.edges.map(({ node }, i) => (
<li key={i}>
{node.canonical
? beautifyInterpretedName(node.canonical.name)
: <em>unnamed</em>}{" "}
— Owner <code>{node.owner?.address ?? "0x0"}</code>
</li>
))}
</ul>
</div>
);
}

Notice we’re selecting the same fields (canonical { name }, owner { address }) on the parent Domain and on each subdomain. Extract a DomainFragment to deduplicate the selection — and get a reusable, fully-typed shape for components that render a Domain.

src/DomainView.tsx
import {
type FragmentOf,
graphql,
readFragment,
useOmnigraphQuery,
} from "enskit/react/omnigraph";
import { asInterpretedName, beautifyInterpretedName } from "enssdk";
const DomainFragment = graphql(`
fragment DomainFragment on Domain {
__typename
canonical { name }
owner { address }
}
`);
const DomainByNameQuery = graphql(
`
query DomainByName($name: InterpretedName!) {
domain(by: { name: $name }) {
...DomainFragment
subdomains {
edges { node { ...DomainFragment } }
}
}
}
`,
[DomainFragment],
);
function RenderDomain({ data }: { data: FragmentOf<typeof DomainFragment> }) {
// type-safe access to fragment data!
const domain = readFragment(DomainFragment, data);
return (
<>
<span>
{domain.canonical
? beautifyInterpretedName(domain.canonical.name)
: "Unnamed Domain"}
</span>{" "}
<span>({domain.__typename})</span>{" "}
<span>
— Owner <code>{domain.owner?.address ?? "0x0"}</code>
</span>
</>
);
}
export function DomainView() {
const name = asInterpretedName("eth");
const [result] = useOmnigraphQuery({
query: DomainByNameQuery,
variables: { name },
});
const { data, fetching, error } = result;
if (!data && fetching) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data?.domain) return <p>No domain found.</p>;
return (
<div>
<h2><RenderDomain data={data.domain} /></h2>
<h3>Subdomains</h3>
<ul>
{data.domain.subdomains?.edges.map(({ node }, i) => (
<li key={i}>
<RenderDomain data={node} />
</li>
))}
</ul>
</div>
);
}

FragmentOf<typeof DomainFragment> is the opaque type for any selection that includes ...DomainFragmentRenderDomain accepts any of them. readFragment(DomainFragment, data) unwraps that opaque type to the typed fields you declared.

subdomains is a Relay Connection — page through it with the first and after arguments. Add pageInfo { hasNextPage endCursor } to the query, track the cursor in component state, and wire up a “Next page” button.

src/DomainView.tsx
import { useState } from "react";
// ...other imports
const DomainByNameQuery = graphql(
`
query DomainByName($name: InterpretedName!, $first: Int!, $after: String) {
domain(by: { name: $name }) {
...DomainFragment
subdomains(first: $first, after: $after) {
edges { node { ...DomainFragment } }
pageInfo { hasNextPage endCursor }
}
}
}
`,
[DomainFragment],
);
const PAGE_SIZE = 20;
export function DomainView() {
const name = asInterpretedName("eth");
const [after, setAfter] = useState<string | null>(null);
const [result] = useOmnigraphQuery({
query: DomainByNameQuery,
variables: { name, first: PAGE_SIZE, after },
});
const { data, fetching, error } = result;
if (!data && fetching) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data?.domain) return <p>No domain found.</p>;
const { subdomains } = data.domain;
return (
<div>
<h2><RenderDomain data={data.domain} /></h2>
<h3>Subdomains</h3>
<ul>
{subdomains?.edges.map(({ node }, i) => (
<li key={i}>
<RenderDomain data={node} />
</li>
))}
</ul>
{subdomains?.pageInfo.hasNextPage && (
<button
type="button"
disabled={fetching}
onClick={() => setAfter(subdomains.pageInfo.endCursor)}
>
{fetching ? "Loading..." : "Next page"}
</button>
)}
</div>
);
}
Terminal window
VITE_ENSNODE_URL=https://api.alpha.ensnode.io npm run dev

Open the printed URL and you should see the eth Domain, its owner, and the first page of its subdomains. Clicking Next page advances the cursor.

  • Swap the hardcoded "eth" for a name from props or a router — see EnsureInterpretedName in the example app for safe handling of user-provided names.
  • See the Omnigraph Cookbook for ready-to-copy queries: account-owned domains, events, registrar permissions, full-text search, and more.
  • See the Omnigraph Schema Reference for the full set of types, fields, and arguments you can query.
  • Need data outside React? Use enssdk directly with the same graphql(...) helper.