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.
1. Scaffold a React app
Section titled “1. Scaffold a React 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:
npm create vite@latest my-ens-app -- --template react-tscd my-ens-appnpm install2. Install enskit and enssdk
Section titled “2. Install enskit and enssdk”npm install enskit@1.13.1 enssdk@1.13.1Always pin exact versions (no ^ or ~) of enskit and enssdk, and keep them on the same version. The Omnigraph GraphQL schema is bundled inside enssdk and consumed by the gql.tada TypeScript plugin to type your queries — a minor or patch bump can change the schema and silently drift your generated types away from your queries. Locking exact versions keeps types and runtime in sync.
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:
{ "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:
{ "js/ts.tsdk.path": "node_modules/typescript/lib", "js/ts.tsdk.promptToUseWorkspaceVersion": true}4. Mount the OmnigraphProvider
Section titled “4. Mount the OmnigraphProvider”OmnigraphProvider is what useOmnigraphQuery reads from. Construct an EnsNodeClient, extend it with the omnigraph module, and wrap your app:
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-instancesconst ENSNODE_URL = import.meta.env.VITE_ENSNODE_URL!
// create and extend an EnsNodeClient with Omnigraph supportconst client = createEnsNodeClient({ url: ENSNODE_URL }).extend(omnigraph);
export function App() { return ( <StrictMode> <OmnigraphProvider client={client}> <h1>My ENS App</h1> <DomainView /> </OmnigraphProvider> </StrictMode> );}5. Hello world
Section titled “5. Hello world”Create src/DomainView.tsx. We’ll start with the simplest possible query — look up the eth Domain and render its owner and protocol version.
An InterpretedName is a Name whose labels are each either normalized or represented as an encoded labelhash (e.g. [abcd...].eth) — the canonical, lossless form ENSNode uses to identify a Name. asInterpretedName("eth") brands a known-safe string as one; for user input, validate first. See Interpreted Name in the terminology reference.
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 overresult.dataand you’ll see it’s typed exactly to your selection set — try removingowner { address }from the query and watch the access below become a type error.domainis a union ofENSv1Domain | ENSv2Domain(both implement theDomaininterface). The Omnigraph unifies ENSv1 and ENSv2 behind the same query —__typenametells you which one you got.canonicalmay benullfor non-canonical names (e.g. Domains whose name cannot be inferred). Always guard the access; TypeScript will help you.
6. List subdomains
Section titled “6. List subdomains”Expand the query to also fetch the Domain’s subdomains. subdomains is a Relay Connection, so the shape is { edges: [{ node }] }.
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> );}7. Extract a typed fragment
Section titled “7. Extract a typed fragment”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.
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 ...DomainFragment — RenderDomain accepts any of them. readFragment(DomainFragment, data) unwraps that opaque type to the typed fields you declared.
8. Paginate with “Load more”
Section titled “8. Paginate with “Load more””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.
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> );}9. Run it
Section titled “9. Run it”VITE_ENSNODE_URL=https://api.alpha.ensnode.io npm run devOpen 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.
Where to go next
Section titled “Where to go next”- Swap the hardcoded
"eth"for a name from props or a router — seeEnsureInterpretedNamein 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
enssdkdirectly with the samegraphql(...)helper.