Skip to navigation

React embed SDK

@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

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';.

'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:

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 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 page covers the field’s contract.

Props

PropTypeNotes
surface'spreading' | 'documents'Which surface to render.
loanIdstringLoan the surface is scoped to. Must match the loan the handle was minted for.
getHandle(ctx) => Promise<string> | stringMints a fresh single-use handle. Called for the initial load and every refresh.
onNav(payload) => voidCalled 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) => voidFrame 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) => voidFired once the frame shell has mounted.
onError(payload) => voidFrame error; fatal distinguishes severity.
embedOriginstringOverride 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?) => voidDropped or rejected protocol messages.
title / className / style / sandbox / initialHeightIframe 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:

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.