> This page is for Embed.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# React embed SDK

> The @spreadspace/react component for embedding SpreadSpace: iframe rendering, auto-resize, back navigation, theme sync, and token refresh.

**`@spreadspace/react`** embeds a SpreadSpace surface in your React application
through a secure, token-isolated `<iframe>`. The `<SpreadSpaceEmbed />` component
renders the surface and wires the host side of the embed protocol: auto-resize,
back navigation, theme sync, and token refresh that never exposes a token
to your page.

`react` and `react-dom` are peer dependencies (18 or 19).

## Install

```bash
npm i @spreadspace/react
```

## Quick start

Render `<SpreadSpaceEmbed />` with a `getHandle` callback. `getHandle` calls
**your** backend, which mints a single-use embed handle server-side and returns
the `signed_url` from the response. The SDK loads that URL as the iframe source.
The callback runs on the initial load and again on every token refresh, so your
page never holds a long-lived credential.

The component uses React state and effects, so in Next.js it renders from a
client component: the file below starts with `'use client';`.

```tsx
'use client';

import { SpreadSpaceEmbed, type GetHandle } from '@spreadspace/react';

export function LoanWorkspace({ loanId }: { loanId: string }) {
  // getHandle calls YOUR backend, which mints a handle server-side and returns
  // the `signed_url`. It receives { surface, loanId, reason? } and returns a
  // full embed URL, never a token.
  const getHandle: GetHandle = async ({ surface, loanId }) => {
    const res = await fetch('/my-backend/spreadspace/iframe-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ surface, loanId }),
    });
    const { signed_url } = await res.json();
    return signed_url;
  };

  return (
    <SpreadSpaceEmbed
      surface="spreading"
      loanId={loanId}
      getHandle={getHandle}
      theme="dark"
    />
  );
}
```

Your backend endpoint mints the handle and returns its `signed_url`. It reads
the embed-minting key, the one that holds `embed:write`, from
`SPREADSPACE_EMBED_API_KEY`. In TypeScript, with `@spreadspace/sdk`:

```ts
import { SpreadSpace } from '@spreadspace/sdk';

const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });

// The page sends { surface, loanId }. loan_id is the name on the SpreadSpace wire.
app.post('/my-backend/spreadspace/iframe-url', express.json(), async (req, res) => {
  const handle = await client.embed.iframeUrls.create({
    loan_id: req.body.loanId,
    surface: req.body.surface,
    scopes: ['documents:read', 'extractions:read', 'spreads:read'],
    external_user_id: req.session.userId,
  });
  res.json({ signed_url: handle.signed_url });
});
```

[Mint from your server](/embed/server) has the same mint in Python, C# and
cURL, and shows how to name write scopes so an analyst can finalize or upload.

`external_user_id` keeps the viewing user's boards, view settings, and memo
templates across sessions. Derive it from your backend's own session rather
than the browser request, so one user cannot open another's saved state. The
[Mint from your server](/embed/server#per-user-state) page covers the field's
contract.

## Props

| Prop                                                          | Type                                 | Notes                                                                                                                                                                                                               |
| ------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `surface`                                                     | `'spreading' \| 'documents'`         | Which surface to render.                                                                                                                                                                                            |
| `loanId`                                                      | `string`                             | Loan the surface is scoped to. Must match the loan the handle was minted for.                                                                                                                                       |
| `getHandle`                                                   | `(ctx) => Promise<string> \| string` | Mints a fresh single-use handle. Called for the initial load and every refresh.                                                                                                                                     |
| `onNav`                                                       | `(payload) => void`                  | Called when the frame posts a `nav` message. The hosted surfaces post none: upload runs inside the frame when the session holds `documents:write`.                                                                  |
| `onUiState`                                                   | `(payload) => void`                  | Frame chrome state: `modalOpen` (a blocking overlay is up in the frame) and `canNavigateBack`. Sent once after ready and on every change.                                                                           |
| `theme`                                                       | `'light' \| 'dark'`                  | Pushed to the frame on mount and on change.                                                                                                                                                                         |
| `onReady`                                                     | `(payload) => void`                  | Fired once the frame shell has mounted.                                                                                                                                                                             |
| `onError`                                                     | `(payload) => void`                  | Frame error; `fatal` distinguishes severity.                                                                                                                                                                        |
| `embedOrigin`                                                 | `string`                             | Override the embed app origin. Only needed when `getHandle` returns a bare handle id instead of the `signed_url`.                                                                                                   |
| `heightMode`                                                  | `'grow' \| 'fill'`                   | `grow` (default) sizes the iframe to the frame's reported content height, so your page scrolls; `fill` makes the iframe `height: 100%` of its container and the frame scrolls internally, ignoring `initialHeight`. |
| `onDiagnostic`                                                | `(message, detail?) => void`         | Dropped or rejected protocol messages.                                                                                                                                                                              |
| `title` / `className` / `style` / `sandbox` / `initialHeight` |                                      | Iframe element passthroughs.                                                                                                                                                                                        |

The component forwards a `ref` exposing `{ reload(), navigateBack(), iframe }`.
Call `reload()` to mint a fresh handle and reload the frame, for example after
the user re-authenticates. `navigateBack()` asks the frame to step its own
navigation back once, and does nothing when the frame has nowhere to go.

## The security model

Your page never holds a SpreadSpace token. You give the SDK a `getHandle`
function that calls your backend, which mints a short-lived, single-use handle
and returns a `signed_url`. The SDK loads that URL as the iframe source. Inside
the iframe, which is same-origin to SpreadSpace, the handle is exchanged for the
real embed token, which stays inside the iframe. When the token nears expiry or
is rejected, the iframe asks your page over `postMessage` for a fresh handle,
the SDK calls `getHandle` again, and the iframe re-exchanges it. A handle is
worthless off SpreadSpace's origin and expires in seconds; the token never
crosses the iframe boundary into your page.

The minted token holds the scopes the mint names and is locked to the single
`loan_id` the handle was minted for. Read scopes are granted by mint
authority; `documents:write`, `extractions:write`, `extractions:edit` and
`spreads:write` are granted only when the mint names them and only if the
minting key holds them. Inspect the returned `scopes` if your integration
depends on it.

## Scopes

The API key your backend uses to mint handles needs the **`embed:write`** scope.
Issue a key from the dashboard at **Settings → Live API** (or **Settings →
Sandbox API** for a test key) and grant it `embed:write`. The samples read
that key from `SPREADSPACE_EMBED_API_KEY`. The browser-facing token minted from that handle never carries
`embed:write`, so a leaked browser token cannot mint further tokens or widen
its own scope.

## Advanced: the protocol module

The typed message envelope and both messengers are exported, also as
`@spreadspace/react/postmessage`, for integrators who want to drive a
hand-rolled iframe:

```ts
import { createHostMessenger, type NavPayload } from '@spreadspace/react/postmessage';
```

`createHostMessenger` verifies the frame origin, pins inbound messages to your
iframe's `contentWindow`, correlates each token-refresh request and response by
id, and replies with a handle. The response payload is type-branded so that
including a token field is a compile error.