> For the complete documentation index, see [llms.txt](https://docs.talus.network/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.talus.network/talus-docs-v2.1.0/guides/nexus-api/tutorial/05-port-to-react.md).

# 5️⃣ Port dApp to React

### Port dApp to React

{% hint style="info" %}
**Audience:** A developer with the raw-HTTP Run Watcher from Step 4.

**Goal:** Build the same consumer with React, page-local public types, fetch, and EventSource-compatible behavior while the API key remains in the relay.
{% endhint %}

The React app is an external consumer of the hosted API. It does not install a private package, import implementation code, or place a credential in browser JavaScript. The app calls same-origin relay paths, validates the public response shape, renders REST state, and uses the SSE stream as a replayable wake-up signal.

<figure><picture><source srcset="/files/RoebmHJM7HNsxnSHdZrB" media="(prefers-color-scheme: dark)"><img src="https://3395888576-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLPrUNT846cHDCVQcRD3f%2Fuploads%2Fgit-blob-9812c0dbca9cf3b6c745e71f7a83012792268c68%2Ff16-react-relay-light.svg?alt=media" alt="React browser through a same-origin relay"></picture><figcaption></figcaption></figure>

The architecture figure makes the browser boundary explicit: only the relay can attach the key, while the React bundle contains public paths and types only. Keep the endpoint provisional until the provider publishes the deployment URL.

```mermaid
sequenceDiagram
  participant Browser as React app
  participant Relay as Same-origin relay
  participant API as Hosted Nexus API
  participant Log as Event log
  Browser->>Relay: GET /executions
  Relay->>API: Add provider-issued x-api-key
  API-->>Relay: JSON status and diagnostics
  Relay-->>Browser: Public response without secret
  Browser->>Relay: Open /events/stream
  Relay->>API: Forward cursor and SSE accept header
  API->>Log: Replay after last validated id
  Log-->>Relay: Event frame and keep-alive
  Relay-->>Browser: Relayed event frame
  Browser->>Relay: Re-read executions after event
```

The sequence keeps REST authoritative and SSE replayable under the provider's retention/reset contract. A malformed response, wrong MIME, 401/403, disconnect, or rate limit becomes visible state with a bounded retry action instead of an endless reconnect loop. A fatal stream error closes the current `EventSource`; native reconnect cannot continue, **Retry stream** preserves the last validated cursor, and **Reset cursor and retry** clears it for a provider-documented restart at the current tip. Unmount closes the source and cancels that generation.

### Scaffold the public consumer

Create the app and install the exact versions used by the tested flow. These commands are consumer setup, not repository-maintainer recipes:

```bash
mkdir nexus-api-react
cd nexus-api-react
npm create vite@6.5.0 . -- --template react-ts
npm install --save-exact react@19.2.8 react-dom@19.2.8
npm install --save-dev --save-exact vite@6.4.3 @vitejs/plugin-react@5.0.4 typescript@5.8.3 tsx@4.19.3 @types/node@22.13.4 @types/react@19.2.18 @types/react-dom@19.2.3
npm pkg set scripts.typecheck='tsc --noEmit'
```

Save the shared cursor module as `src/replay-cursor.ts` and the checked App source from this page as `src/App.tsx`. Save the following two files at the app root; the relay owns the provider key and forwards only the public response diagnostics:

```ts
// src/replay-cursor.ts
export type ReplayCursor = string & { readonly __replayCursor: unique symbol };

export function canonicalReplayCursor(value: string | number): ReplayCursor {
  if (typeof value === "number") {
    if (!Number.isSafeInteger(value) || value < 0) throw new Error("replay cursor number is invalid");
    return String(value) as ReplayCursor;
  }
  if (!/^(0|[1-9]\d*)$/.test(value)) throw new Error("replay cursor string is not canonical");
  return value as ReplayCursor;
}

export function transportReplayCursor(raw: string): ReplayCursor | undefined {
  if (raw === "") return undefined;
  const cursor = canonicalReplayCursor(raw);
  if (String(cursor) !== raw) throw new Error("transport replay cursor was normalized");
  return cursor;
}

export function replayCursorIsAfter(previous: ReplayCursor | null, current: ReplayCursor): boolean {
  return previous === null || BigInt(current) > BigInt(previous);
}
```

```ts
// relay-policy.ts
export const FORWARDED_RESPONSE_HEADERS = new Set([
  "content-type",
  "x-request-id",
  "x-error-code",
  "retry-after",
  "ratelimit-limit",
  "ratelimit-remaining",
  "ratelimit-reset",
]);

export type ProxyResponseHeaders = Record<string, string | string[] | undefined>;

export function applyResponsePolicy(path: string, responseHeaders: ProxyResponseHeaders): void {
  for (const name of Object.keys(responseHeaders)) {
    if (!FORWARDED_RESPONSE_HEADERS.has(name.toLowerCase())) delete responseHeaders[name];
  }
  responseHeaders["cache-control"] = path === "/events/stream" ? "no-cache, no-transform" : "no-store";
  if (path === "/events/stream") responseHeaders["x-accel-buffering"] = "no";
}
```

```ts
// vite.config.ts
import { defineConfig, type ProxyOptions, type UserConfig } from "vite";
import react from "@vitejs/plugin-react";
import { applyResponsePolicy } from "./relay-policy";

function requireHttpsTarget(raw: string): string {
  let parsed: URL;
  try {
    parsed = new URL(raw);
  } catch {
    throw new Error("NEXUS_API_URL must be an absolute https URL");
  }
  if (parsed.protocol !== "https:") throw new Error("NEXUS_API_URL must use https before an API key is attached");
  if (parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("NEXUS_API_URL must be an origin-root https URL without a path, query, or fragment");
  return parsed.origin;
}

export function createRelayProxyConfig(target: string | undefined, apiKey: string | undefined): Record<string, ProxyOptions> {
  if (!target) throw new Error("NEXUS_API_URL must be set in the Vite server process");
  if (!apiKey) throw new Error("NEXUS_API_KEY must be set in the Vite server process");
  const secureTarget = requireHttpsTarget(target);
  const headers = { "x-api-key": apiKey };
  type ProxyConfigure = NonNullable<ProxyOptions["configure"]>;
  const configure = (path: string): ProxyConfigure => (proxy) => {
    proxy.on("proxyReq", (proxyRequest, request) => {
      const incoming = new URL(request.url ?? path, "http://relay.invalid");
      proxyRequest.path = `${path}${incoming.search}`;
      request.on("aborted", () => proxyRequest.destroy());
    });
    proxy.on("proxyRes", (response) => applyResponsePolicy(path, response.headers));
  };
  return {
    "/executions": { target: secureTarget, headers, configure: configure("/executions") },
    "/events/stream": { target: secureTarget, headers, configure: configure("/events/stream") },
  };
}

export function createViteConfig(target = process.env.NEXUS_API_URL, apiKey = process.env.NEXUS_API_KEY): UserConfig {
  return { plugins: [react()], server: { proxy: createRelayProxyConfig(target, apiKey) } };
}

export default defineConfig(() => createViteConfig());
```

The relay fixture injects the key in the server process and forwards only public diagnostics and content type. It sets local REST no-store and SSE no-cache, drops cookies, redirects, authentication challenges, CSP, upstream cache, and body-framing headers, and leaves the browser unable to see the provider credential. It enforces the same origin-root `NEXUS_API_URL` contract as the raw and full-stack examples; a provider base path must be handled by a separate adapter that preserves it.

#### Replace App.tsx

Replace `src/App.tsx` with the checked source in [react-app/App.tsx](https://github.com/Talus-Network/nexus-docs/tree/v2.1.0/guides/nexus-api/tutorial/react-app/App.tsx):

```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { canonicalReplayCursor, replayCursorIsAfter, transportReplayCursor, type ReplayCursor } from "./replay-cursor";

type ExecutionSummary = {
  object_id: string;
  status: string;
  created_at: string;
};

type EventFrame = {
  id: number;
  kind: string;
  tx_digest?: string;
  sender_address: string;
  payload: unknown;
};

type ListEnvelope<T> = {
  items: T[];
  metadata?: { next_token?: number | null };
};

const KINDS = ["RequestWalkExecution", "WalkAdvanced", "WalkFailed", "ExecutionFinished"];

function isExecution(value: unknown): value is ExecutionSummary {
  if (!value || typeof value !== "object") return false;
  const item = value as Partial<ExecutionSummary>;
  return typeof item.object_id === "string" && typeof item.status === "string" && typeof item.created_at === "string";
}

function isEvent(value: unknown): value is EventFrame {
  if (!value || typeof value !== "object") return false;
  const event = value as Partial<EventFrame>;
  const id = event.id;
  const hasTransactionDigest = Object.prototype.hasOwnProperty.call(event, "tx_digest");
  const hasPayload = Object.prototype.hasOwnProperty.call(event, "payload");
  return typeof id === "number" && Number.isSafeInteger(id) && id >= 0 && typeof event.kind === "string" && typeof event.sender_address === "string" && hasPayload && (!hasTransactionDigest || (typeof event.tx_digest === "string" && event.tx_digest.length > 0));
}

function validatedEventId(message: MessageEvent, event: EventFrame): ReplayCursor {
  const transportId = message.lastEventId;
  const payloadId = canonicalReplayCursor(event.id);
  const transportCursor = transportReplayCursor(transportId);
  if (transportCursor !== undefined && transportCursor !== payloadId) throw new Error("transport and payload event ids differ");
  return payloadId;
}

export default function App() {
  const [executions, setExecutions] = useState<ExecutionSummary[]>([]);
  const [events, setEvents] = useState<EventFrame[]>([]);
  const [restState, setRestState] = useState("loading");
  const [streamState, setStreamState] = useState("starting");
  const [streamGeneration, setStreamGeneration] = useState(0);
  const lastValidatedEventId = useRef<ReplayCursor | null>(null);
  const retryPending = useRef(false);

  const refresh = useCallback(async () => {
    setRestState("loading");
    try {
      const response = await fetch("/executions?page_size=20", { headers: { accept: "application/json" } });
      const contentType = response.headers.get("content-type") ?? "";
      const text = await response.text();
      const body: unknown = contentType.includes("application/json") && text ? JSON.parse(text) : text;
      if (!response.ok) throw new Error(`REST ${response.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
      if (!body || typeof body !== "object" || !Array.isArray((body as ListEnvelope<unknown>).items)) {
        throw new Error("REST response did not match the public list envelope");
      }
      const items = (body as ListEnvelope<unknown>).items;
      if (!items.every(isExecution)) throw new Error("REST response contained an invalid execution");
      setExecutions(items);
      setRestState("healthy");
    } catch (error) {
      setRestState(error instanceof Error ? error.message : "REST request failed");
    }
  }, []);

  const retryStream = useCallback(() => {
    if (retryPending.current) return;
    retryPending.current = true;
    setStreamGeneration((generation) => generation + 1);
  }, []);

  const resetStreamCursor = useCallback(() => {
    lastValidatedEventId.current = null;
    setEvents([]);
    retryPending.current = false;
    setStreamGeneration((generation) => generation + 1);
  }, []);

  useEffect(() => {
    retryPending.current = false;
    void refresh();
    let stopped = false;
    const streamUrl = new URL("/events/stream", window.location.origin);
    streamUrl.searchParams.set("kinds", KINDS.join(","));
    if (lastValidatedEventId.current !== null) {
      streamUrl.searchParams.set("last_event_id", lastValidatedEventId.current);
    }
    const source = new EventSource(streamUrl.toString());
    const stopStream = (message: string) => {
      if (stopped) return;
      stopped = true;
      source.close();
      setStreamState(message);
    };
    source.onopen = () => {
      if (!stopped) setStreamState("healthy");
    };
    source.onmessage = (message) => {
      try {
        const value: unknown = JSON.parse(message.data);
        if (!isEvent(value)) throw new Error("event schema is invalid");
        const eventId = validatedEventId(message, value);
        const previousId = lastValidatedEventId.current;
        if (!replayCursorIsAfter(previousId, eventId)) throw new Error("event id is duplicate or moved backwards");
        lastValidatedEventId.current = eventId;
        setEvents((current) => [value, ...current].slice(0, 50));
        void refresh();
      } catch {
        stopStream("invalid event; stream stopped; retry explicitly");
      }
    };
    source.onerror = () => stopStream("stream stopped; retry explicitly");
    return () => {
      stopped = true;
      source.close();
    };
  }, [refresh, streamGeneration]);

  return (
    <main>
      <h1>Run Watcher</h1>
      <p>REST: {restState} · stream: {streamState}</p>
      <button type="button" onClick={() => void refresh()}>Retry REST</button>
      <button type="button" onClick={retryStream}>Retry stream</button>
      <button type="button" onClick={resetStreamCursor}>Reset cursor and retry</button>
      <table>
        <thead><tr><th>Execution</th><th>Status</th><th>Created</th></tr></thead>
        <tbody>
          {executions.map((execution) => (
            <tr key={execution.object_id}>
              <td>{execution.object_id.slice(0, 18)}…</td>
              <td>{execution.status}</td>
              <td>{execution.created_at}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <h2>Live events</h2>
      <ul>
        {events.map((event) => <li key={event.id}>#{event.id} {event.kind} {event.tx_digest ?? ""}</li>)}
      </ul>
    </main>
  );
}
```

The source block and the repository fixture are byte-aligned by the React-flow gate. The code validates list and event shapes, uses text interpolation only for React text nodes, closes the EventSource on malformed data, and exposes a visible REST retry action. It does not create an API-key header in browser code because the relay owns that header.

#### Run the consumer

Keep the key in the relay's server-side environment, never in a VITE-prefixed variable. Run the public app with the same commands a consumer can use:

```bash
export NEXUS_API_URL="https://api.example.invalid"
read -r -s NEXUS_API_KEY
printf '\n' >&2
export NEXUS_API_KEY
trap 'unset NEXUS_API_KEY' EXIT INT TERM
npm run typecheck
npm run build
npm run dev -- --host 127.0.0.1
```

Maintainers may separately run the repository's relay, React-flow, audit, and secret-regression gates against temporary copies. Those maintenance commands are not prerequisites for an external consumer. A provider outage or a provisional URL is a service boundary, not a reason to weaken the credential rule.

{% hint style="success" %}
**Checkpoint.** The browser Network panel shows same-origin requests without x-api-key, the REST table renders validated rows, the stream status is visible, and a malformed event closes the stream instead of becoming executable markup.
{% endhint %}

#### Production boundary

Use a reviewed server relay or backend service for production. Add application authentication, authorization, CSRF protection, observability, and a deployment-specific allowlist while preserving upstream status/body and the public request-id, retry, and rate-limit diagnostics. Never expose the key to a browser bundle, URL, query string, local storage, or source control.

#### Next

Return to [Connect a Nexus API dApp](/talus-docs-v2.1.0/guides/nexus-api/connect-a-dapp.md) for relay patterns, or consult the [HTTP API reference](https://api.taluslabs.dev/docs) for endpoint and status contracts.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.talus.network/talus-docs-v2.1.0/guides/nexus-api/tutorial/05-port-to-react.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `build a script that syncs our docs to a CMS` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
