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 enssdk

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.