diff --git a/packages/ensnode-react/example/LICENSE b/packages/ensnode-react/example/LICENSE
new file mode 100644
index 0000000000..0d70998c14
--- /dev/null
+++ b/packages/ensnode-react/example/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 NameHash
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/ensnode-react/example/README.md b/packages/ensnode-react/example/README.md
new file mode 100644
index 0000000000..dc7f86b633
--- /dev/null
+++ b/packages/ensnode-react/example/README.md
@@ -0,0 +1,48 @@
+# `ensnode-react` Example
+
+> [!IMPORTANT] > **For React integrations with ENSNode, use [`enskit`](../../enskit), not `ensnode-react`.**
+> See the [`enskit` React example](../../../examples/enskit-react-example).
+>
+> This app is internaldocumentation for refining `ensnode-react` before its functionality
+> is folded into `enskit`.
+
+## What it demonstrates
+
+A bulletproof reference for managing a frontend's connection to an ENSNode instance.
+Resolving a Mainnet Primary Name via `usePrimaryName` is just the payoff. The
+interesting part is everything that gates it:
+
+1. **Connection negotiation.** Wait for a healthy `useIndexingStatus` response
+2. **Disambiguated error handling.** Connection failures are classified as `network`
+ (fetch / DNS / CORS), `application` (bad response, or `responseCode === "error"`),
+ or `unsupported-namespace`. See
+ [`classify-connection-error.ts`](./src/lib/classify-connection-error.ts) and
+ [`RequireActiveConnection.tsx`](./src/components/RequireActiveConnection.tsx).
+3. **Explicit namespace verification.** The app hardcodes an expected ENS namespace
+ (defaulting to `mainnet`, overridable via `VITE_ENS_NAMESPACE`) and refuses
+ connection if the ENSNode is indexing something else.
+4. **Live indexing-status projection.** An
+ [`IndexingStatusBadge`](./src/components/IndexingStatusBadge.tsx) shows how far
+ behind realtime the connected ENSNode is, modeled on ENSAdmin's `ProjectionInfo`.
+
+`PrimaryNameView` is intentionally thin; the connection scaffolding is the part worth copying.
+
+## Configuration
+
+| Env var | Default | Purpose |
+| -------------------- | ------------------------------ | ---------------------------------------------------------------------- |
+| `VITE_ENSNODE_URL` | `https://api.alpha.ensnode.io` | URL of the ENSNode instance to connect to. |
+| `VITE_ENS_NAMESPACE` | `mainnet` | Expected ENS namespace. Connection is refused if the server disagrees. |
+
+## Usage
+
+```bash
+pnpm install
+pnpm -F @ensnode/ensnode-react-example dev
+```
+
+Point at a different ENSNode and/or namespace:
+
+```bash
+VITE_ENSNODE_URL=http://localhost:4334 VITE_ENS_NAMESPACE=sepolia pnpm -F @ensnode/ensnode-react-example dev
+```
diff --git a/packages/ensnode-react/example/index.html b/packages/ensnode-react/example/index.html
new file mode 100644
index 0000000000..2db51df750
--- /dev/null
+++ b/packages/ensnode-react/example/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ ensnode-react Example
+
+
+
+
+
+
diff --git a/packages/ensnode-react/example/package.json b/packages/ensnode-react/example/package.json
new file mode 100644
index 0000000000..819fa962a7
--- /dev/null
+++ b/packages/ensnode-react/example/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@ensnode/ensnode-react-example",
+ "private": true,
+ "version": "0.0.1",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "tsgo --noEmit"
+ },
+ "dependencies": {
+ "@ensnode/datasources": "workspace:*",
+ "@ensnode/ensnode-react": "workspace:*",
+ "@ensnode/ensnode-sdk": "workspace:*",
+ "enssdk": "workspace:*",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "^4.5.2",
+ "typescript": "catalog:",
+ "vite": "catalog:"
+ }
+}
diff --git a/packages/ensnode-react/example/src/App.tsx b/packages/ensnode-react/example/src/App.tsx
new file mode 100644
index 0000000000..2da97deb14
--- /dev/null
+++ b/packages/ensnode-react/example/src/App.tsx
@@ -0,0 +1,36 @@
+import { StrictMode } from "react";
+
+import { createEnsNodeProviderOptions, EnsNodeProvider } from "@ensnode/ensnode-react";
+
+import { IndexingStatusBadge } from "./components/IndexingStatusBadge";
+import { RequireActiveConnection } from "./components/RequireActiveConnection";
+import { ENSNODE_URL, EXPECTED_NAMESPACE } from "./config";
+import { PrimaryNameView } from "./PrimaryNameView";
+
+const options = createEnsNodeProviderOptions({ url: ENSNODE_URL });
+
+export function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ensnode-react/example/src/PrimaryNameView.tsx b/packages/ensnode-react/example/src/PrimaryNameView.tsx
new file mode 100644
index 0000000000..eeff9e90a7
--- /dev/null
+++ b/packages/ensnode-react/example/src/PrimaryNameView.tsx
@@ -0,0 +1,147 @@
+import {
+ type ChainId,
+ DEFAULT_EVM_CHAIN_ID,
+ type DefaultableChainId,
+ type NormalizedAddress,
+ toNormalizedAddress,
+} from "enssdk";
+import { useId, useMemo, useState } from "react";
+
+import {
+ DatasourceNames,
+ type ENSNamespaceId,
+ getENSRootChain,
+ maybeGetDatasource,
+} from "@ensnode/datasources";
+import { usePrimaryName } from "@ensnode/ensnode-react";
+
+import { EXPECTED_NAMESPACE } from "./config";
+
+const DEFAULT_INPUT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; // vitalik.eth
+const DEFAULT_ADDRESS: NormalizedAddress = toNormalizedAddress(DEFAULT_INPUT);
+
+const REVERSE_RESOLVER_DATASOURCES = [
+ DatasourceNames.ReverseResolverBase,
+ DatasourceNames.ReverseResolverLinea,
+ DatasourceNames.ReverseResolverOptimism,
+ DatasourceNames.ReverseResolverArbitrum,
+ DatasourceNames.ReverseResolverScroll,
+] as const;
+
+interface ChainOption {
+ id: DefaultableChainId;
+ label: string;
+}
+
+/**
+ * Builds the ENSIP-19 chain options for the picker:
+ * default EVM chain (0), the ENS root chain, then any reverse-resolver chains
+ * exposed by the active namespace. Matches the composition in
+ * `apps/ensadmin/src/app/inspect/primary-name/page.tsx`.
+ */
+// TODO: candidate for promotion to `@ensnode/datasources` (no React) duplicates the chain composition in `apps/ensadmin/src/app/inspect/primary-name/page.tsx` + `apps/ensadmin/src/lib/get-ensip19-supported-chain-ids.ts`.
+function getENSIP19ChainOptions(namespace: ENSNamespaceId): ChainOption[] {
+ const root = getENSRootChain(namespace);
+ const options: ChainOption[] = [
+ { id: DEFAULT_EVM_CHAIN_ID, label: "Default EVM Chain Address (ENSIP-19)" },
+ { id: root.id, label: `${root.name} — ENS Root` },
+ ];
+
+ const seen = new Set([DEFAULT_EVM_CHAIN_ID, root.id]);
+ for (const name of REVERSE_RESOLVER_DATASOURCES) {
+ const ds = maybeGetDatasource(namespace, name);
+ if (!ds || seen.has(ds.chain.id)) continue;
+ seen.add(ds.chain.id);
+ options.push({ id: ds.chain.id, label: ds.chain.name });
+ }
+
+ return options;
+}
+
+export function PrimaryNameView() {
+ const addressInputId = useId();
+ const chainSelectId = useId();
+
+ const chainOptions = useMemo(() => getENSIP19ChainOptions(EXPECTED_NAMESPACE), []);
+
+ const [address, setAddress] = useState(DEFAULT_ADDRESS);
+ const [chainId, setChainId] = useState(
+ getENSRootChain(EXPECTED_NAMESPACE).id,
+ );
+ const [input, setInput] = useState(DEFAULT_INPUT);
+ const [inputError, setInputError] = useState(null);
+
+ const { data, isLoading, error } = usePrimaryName({
+ address,
+ chainId,
+ accelerate: true,
+ });
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ try {
+ setAddress(toNormalizedAddress(input.trim()));
+ setInputError(null);
+ } catch (err) {
+ setInputError(err instanceof Error ? err.message : "Invalid EVM address.");
+ }
+ };
+
+ return (
+
+ Primary Name
+
+ Resolves the ENSIP-19 Primary Name for an address on a selected chain using{" "}
+ usePrimaryName. Because ENSIP-19 is multichain, pick which chain's primary name
+ you want to read.
+
+
+
+
+ {isLoading && Loading…
}
+ {error && Error: {error.message}
}
+ {data && (
+
+ Primary Name: {data.name ?? "(none)"}
+
+ )}
+
+ );
+}
diff --git a/packages/ensnode-react/example/src/components/IndexingStatusBadge.tsx b/packages/ensnode-react/example/src/components/IndexingStatusBadge.tsx
new file mode 100644
index 0000000000..e1cfff9148
--- /dev/null
+++ b/packages/ensnode-react/example/src/components/IndexingStatusBadge.tsx
@@ -0,0 +1,54 @@
+import type { Duration, UnixTimestamp } from "enssdk";
+
+import { useIndexingStatus } from "@ensnode/ensnode-react";
+import { EnsApiIndexingStatusResponseCodes } from "@ensnode/ensnode-sdk";
+
+function formatWorstCaseDistance(distance: Duration): string {
+ if (distance <= 60) return `${distance}s behind`;
+ if (distance <= 60 * 60) return `${Math.round(distance / 60)}m behind`;
+ if (distance <= 60 * 60 * 24) return `${Math.round(distance / (60 * 60))}h behind`;
+ return `${Math.round(distance / (60 * 60 * 24))}d behind`;
+}
+
+function formatSnapshotAge(snapshotTime: UnixTimestamp, now: UnixTimestamp): string {
+ const age = Math.max(0, now - snapshotTime);
+ if (age < 60) return `${age}s ago`;
+ if (age < 60 * 60) return `${Math.round(age / 60)}m ago`;
+ return `${Math.round(age / (60 * 60))}h ago`;
+}
+
+/**
+ * Compact indexing-status indicator inspired by the ENSAdmin `ProjectionInfo` info-icon.
+ *
+ * Polls the connected ENSNode's `/api/indexing-status` endpoint (via
+ * `useIndexingStatus`) and renders the worst-case projection distance plus
+ * snapshot freshness so consumers can see at a glance how far behind realtime
+ * the connected ENSNode is.
+ */
+export function IndexingStatusBadge() {
+ const { data, isLoading, error } = useIndexingStatus();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!data || data.responseCode !== EnsApiIndexingStatusResponseCodes.Ok) {
+ return ;
+ }
+
+ const { projectedAt, worstCaseDistance, snapshot } = data.realtimeProjection;
+
+ return (
+
+ );
+}
diff --git a/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx b/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx
new file mode 100644
index 0000000000..ed34b36096
--- /dev/null
+++ b/packages/ensnode-react/example/src/components/RequireActiveConnection.tsx
@@ -0,0 +1,92 @@
+import type { ReactNode } from "react";
+
+import { useIndexingStatus } from "@ensnode/ensnode-react";
+import { EnsApiIndexingStatusResponseCodes } from "@ensnode/ensnode-sdk";
+
+import { ENSNODE_URL, EXPECTED_NAMESPACE } from "../config";
+import { classifyConnectionError } from "../lib/classify-connection-error";
+
+interface RequireActiveConnectionProps {
+ children: ReactNode;
+}
+
+/**
+ * Protects child rendering on a healthy, namespace-matching ENSNode connection.
+ *
+ * Modeled after ENSAdmin's `RequireActiveConnection`
+ * (`apps/ensadmin/src/components/connections/require-active-connection.tsx`),
+ * with the additional namespace-mismatch check required by this example app.
+ *
+ * Failure modes are intentionally disambiguated so the UI can tell apart a
+ * network-level problem from an application-level problem:
+ *
+ * 1. `useIndexingStatus` throws (fetch failed, server returned an error response,
+ * or the response failed deserialization) `classifyConnectionError` decides
+ * whether to call this a `network` or `application` failure.
+ * 2. The server answered with `responseCode === "error"` surfaced as an
+ * application-level "ENSNode reported its indexing status is unavailable" state.
+ * 3. The response is OK but `stackInfo.ensIndexer.namespace` does not match
+ * `EXPECTED_NAMESPACE` surfaced as an `unsupported-namespace` mismatch and
+ * the connection is refused.
+ *
+ * Only once we've confirmed (1) we can reach ENSNode, (2) it returned a usable
+ * config, and (3) the namespace matches, do we render `children`.
+ */
+// TODO: candidate for promotion to `enskit` as it duplicates `apps/ensadmin/src/components/connections/require-active-connection.tsx`
+export function RequireActiveConnection({ children }: RequireActiveConnectionProps) {
+ const { data, isLoading, error } = useIndexingStatus();
+
+ if (isLoading) {
+ return (
+
+ Connecting to ENSNode at {ENSNODE_URL.href}…
+
+ );
+ }
+
+ if (error) {
+ const failure = classifyConnectionError(error);
+ return (
+
+ Connection failed ({failure.kind})
+ {failure.message}
+
+ Configured ENSNode: {ENSNODE_URL.href}
+
+
+ );
+ }
+
+ if (!data || data.responseCode === EnsApiIndexingStatusResponseCodes.Error) {
+ return (
+
+ Connection failed (application)
+
+ ENSNode answered, but reported that its indexing status is currently unavailable. The
+ instance may still be starting up or its dependencies (ENSDb, ENSIndexer) are not yet
+ healthy.
+
+
+ );
+ }
+
+ const actualNamespace = data.stackInfo.ensIndexer.namespace;
+ if (actualNamespace !== EXPECTED_NAMESPACE) {
+ return (
+
+ Connection refused (unsupported-namespace)
+
+ This example app was built for ENS namespace {EXPECTED_NAMESPACE}, but the
+ ENSNode at {ENSNODE_URL.href} is indexing namespace{" "}
+ {actualNamespace}.
+
+
+ Re-run with VITE_ENS_NAMESPACE={actualNamespace} or point{" "}
+ VITE_ENSNODE_URL at an instance indexing {EXPECTED_NAMESPACE}.
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/packages/ensnode-react/example/src/config.ts b/packages/ensnode-react/example/src/config.ts
new file mode 100644
index 0000000000..4a09a09af4
--- /dev/null
+++ b/packages/ensnode-react/example/src/config.ts
@@ -0,0 +1,13 @@
+import { type ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources";
+import { ENSNamespaceSchema, makeUrlSchema } from "@ensnode/ensnode-sdk/internal";
+
+// TODO: potential internal to be made public
+const DEFAULT_ENSNODE_URL = "https://api.alpha.green.ensnode.io";
+
+export const ENSNODE_URL: URL = makeUrlSchema("VITE_ENSNODE_URL").parse(
+ import.meta.env.VITE_ENSNODE_URL ?? DEFAULT_ENSNODE_URL,
+);
+
+export const EXPECTED_NAMESPACE: ENSNamespaceId = ENSNamespaceSchema.parse(
+ import.meta.env.VITE_ENS_NAMESPACE ?? ENSNamespaceIds.Mainnet,
+);
diff --git a/packages/ensnode-react/example/src/lib/classify-connection-error.ts b/packages/ensnode-react/example/src/lib/classify-connection-error.ts
new file mode 100644
index 0000000000..0d7fad70c6
--- /dev/null
+++ b/packages/ensnode-react/example/src/lib/classify-connection-error.ts
@@ -0,0 +1,27 @@
+export type ConnectionFailureKind = "network" | "application" | "unsupported-namespace";
+
+export interface ConnectionFailure {
+ kind: ConnectionFailureKind;
+ message: string;
+}
+
+// TODO: abstract out
+export function classifyConnectionError(error: unknown): ConnectionFailure {
+ if (error instanceof TypeError) {
+ return {
+ kind: "network",
+ message:
+ "Could not reach the ENSNode instance. " +
+ "Check the URL, your network, and that the server allows CORS from this origin.",
+ };
+ }
+
+ if (error instanceof Error) {
+ return { kind: "application", message: error.message };
+ }
+
+ return {
+ kind: "application",
+ message: "Unknown error connecting to ENSNode.",
+ };
+}
diff --git a/packages/ensnode-react/example/src/main.tsx b/packages/ensnode-react/example/src/main.tsx
new file mode 100644
index 0000000000..4a1253371e
--- /dev/null
+++ b/packages/ensnode-react/example/src/main.tsx
@@ -0,0 +1,6 @@
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+
+// biome-ignore lint/style/noNonNullAssertion: the #root element definitely exists (see index.html)
+createRoot(document.getElementById("root")!).render();
diff --git a/packages/ensnode-react/example/src/vite-env.d.ts b/packages/ensnode-react/example/src/vite-env.d.ts
new file mode 100644
index 0000000000..c04205cedd
--- /dev/null
+++ b/packages/ensnode-react/example/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_ENSNODE_URL?: string;
+ readonly VITE_ENS_NAMESPACE?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/packages/ensnode-react/example/tsconfig.json b/packages/ensnode-react/example/tsconfig.json
new file mode 100644
index 0000000000..0192fe334b
--- /dev/null
+++ b/packages/ensnode-react/example/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "lib": ["ESNext", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/ensnode-react/example/vite.config.ts b/packages/ensnode-react/example/vite.config.ts
new file mode 100644
index 0000000000..58676f788a
--- /dev/null
+++ b/packages/ensnode-react/example/vite.config.ts
@@ -0,0 +1,6 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 469f7f3d7e..631915dac7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1026,6 +1026,43 @@ importers:
specifier: 'catalog:'
version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3)
+ packages/ensnode-react/example:
+ dependencies:
+ '@ensnode/datasources':
+ specifier: workspace:*
+ version: link:../../datasources
+ '@ensnode/ensnode-react':
+ specifier: workspace:*
+ version: link:..
+ '@ensnode/ensnode-sdk':
+ specifier: workspace:*
+ version: link:../../ensnode-sdk
+ enssdk:
+ specifier: workspace:*
+ version: link:../../enssdk
+ react:
+ specifier: 'catalog:'
+ version: 19.2.1
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.1(react@19.2.1)
+ devDependencies:
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.7)
+ '@vitejs/plugin-react':
+ specifier: ^4.5.2
+ version: 4.7.0(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3))
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ vite:
+ specifier: 'catalog:'
+ version: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3)
+
packages/ensnode-sdk:
dependencies:
'@ensdomains/address-encoder':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 57cbd0d031..e6dd7f889b 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,8 @@ packages:
- docs/*
- examples/*
- packages/*
+ # TODO: remove once we can move into `examples` folder enskit-example
+ - packages/ensnode-react/example
catalog:
"@adraffy/ens-normalize": 1.11.1