To embed a sportsbook in a React or Next.js app, you need two small pieces: a server route that opens a player session with your API key and returns a single-use launch URL, and a client component that loads The Sportsbook's JavaScript SDK once, mounts the iframe with TheSportsbook.init and calls destroy() on unmount. Below is working code for both, plus session renewal, events and TypeScript types. It's a play-for-fun sportsbook: players bet free tokens, never real money.
Key takeaways
- A route handler calls
POST /b2b/v1/sessionswith the key from a server-only env var and returnslaunch_url. The browser never sees the key. - The
<Sportsbook>component loadssdk.jsonce per page, initializes withheight: "auto"and destroys the instance on unmount. That also handles StrictMode's double mount. onSessionExpiredplusupdateToken()renews the 12-hour session without reloading the iframe.bet-placedis a cue to refresh your balance from your server;tokens-requestedopens your free refill. Neither should move tokens by itself.- Types come from
https://play.thesportsbook.app/sdk.d.ts.
On this page10 sections
- Architecture: a route handler and a client component
- The session route
- A <Sportsbook> component
- Cleaning up: destroy() and StrictMode double mounts
- Renewing sessions with updateToken
- Reacting to events
- TypeScript types from sdk.d.ts
- Common problems when you embed a sportsbook in React
- Before you ship
- Frequently asked questions
Architecture: a route handler and a client component
The flow has the same three steps as any sportsbook iframe integration, mapped onto Next.js:
- Server:
app/api/sportsbook/session/route.tsfinds the signed-in user from your own auth, calls the session API and returns a freshlaunch_url. - Client: a
"use client"component fetches that route, loads the SDK and mounts the iframe. - Server again, later: your code manages token balances, either through the managed API or your own wallet.
Why fetch the launch URL from the client instead of rendering it in a Server Component? The launch token works once and expires after 60 seconds. A URL baked into server-rendered HTML is one cache layer away from being served twice or served stale. Fetching it on mount keeps the token's life short and the page cacheable. If you do render it on the server, make sure that page is never cached.
The session route
This is your code. getCurrentUser() stands in for whatever your auth library provides.
// app/api/sportsbook/session/route.ts (your code)import { NextResponse } from "next/server";import { getCurrentUser } from "@/lib/auth"; // your own authexport async function POST() { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "not_signed_in" }, { status: 401 }); const res = await fetch("https://api.thesportsbook.app/b2b/v1/sessions", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPORTSBOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ user_ref: user.id, // stable, pseudonymous id: never an email or real name display_name: user.nickname, // a nickname the player chose locale: "en", }), cache: "no-store", }); if (res.status === 403) return NextResponse.json({ error: "blocked" }, { status: 403 }); if (!res.ok) return NextResponse.json({ error: "session_failed" }, { status: 502 }); const { launch_url } = await res.json(); return NextResponse.json({ launch_url }, { headers: { "Cache-Control": "no-store" } });}Points that matter:
SPORTSBOOK_API_KEYhas noNEXT_PUBLIC_prefix, so Next.js keeps it on the server. The key looks likesbk_<prefix>_<secret>and is shown once when you create it in the console.user_refcomes from your session, never from the request body. If the browser could choose it, anyone could open a session as anyone else.user_refmust be stable (1 to 128 characters). A database id or a random id you store per user works. A display name doesn't, because it changes.- A
403means the player is blocked in your console (USER_BLOCKED). Show a message instead of the sportsbook.
Your server has a budget of 600 requests per minute per API key, which is far more than one call per page view needs.
A <Sportsbook> component
The SDK is served as a script from play.thesportsbook.app. Load it once per page with a shared promise, then create the instance in an effect.
// components/Sportsbook.tsx (your code)"use client";import { useEffect, useRef } from "react";import type { TheSportsbookInstance } from "@/types/sdk"; // copied from /sdk.d.tsconst SDK_SRC = "https://play.thesportsbook.app/sdk.js";let sdkPromise: Promise<void> | null = null;function loadSdk(): Promise<void> { if (window.TheSportsbook) return Promise.resolve(); if (!sdkPromise) { sdkPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = SDK_SRC; script.async = true; script.onload = () => resolve(); script.onerror = () => { sdkPromise = null; reject(new Error("sdk.js failed to load")); }; document.head.appendChild(script); }); } return sdkPromise;}async function fetchLaunchUrl(): Promise<string> { const res = await fetch("/api/sportsbook/session", { method: "POST" }); if (!res.ok) throw new Error(`session ${res.status}`); return (await res.json()).launch_url;}type Props = { onBalanceChange?: () => void; // e.g. refetch your balance widget onWantsTokens?: () => void; // e.g. open your free refill dialog};export function Sportsbook({ onBalanceChange, onWantsTokens }: Props) { const containerRef = useRef<HTMLDivElement>(null); const handlers = useRef({ onBalanceChange, onWantsTokens }); handlers.current = { onBalanceChange, onWantsTokens }; useEffect(() => { let cancelled = false; let instance: TheSportsbookInstance | null = null; (async () => { const [launchUrl] = await Promise.all([fetchLaunchUrl(), loadSdk()]); if (cancelled || !containerRef.current) return; instance = window.TheSportsbook.init({ container: containerRef.current, launchUrl, height: "auto", onBetPlaced() { handlers.current.onBalanceChange?.(); }, onTokensRequested() { handlers.current.onWantsTokens?.(); }, async onSessionExpired(e) { if (e.reason === "user_blocked") return; instance?.updateToken(await fetchLaunchUrl()); }, onError(e) { console.warn("sportsbook error", e.code, e.message); }, }); })().catch((err) => console.error(err)); return () => { cancelled = true; instance?.destroy(); }; }, []); return <div ref={containerRef} />;}A few choices here are deliberate:
- The SDK and the session load in parallel.
Promise.allsaves a round trip, and the launch URL is used within a second or two, well inside its 60 seconds. - Callbacks go through a ref. The effect runs once, so props captured at mount would go stale. Reading
handlers.currentinside the callbacks always calls the latest ones without re-creating the iframe. height: "auto"makes the iframe follow its content, so the page scrolls as one document. Give the container the full width of your content column: the sportsbook's layout follows the iframe's width, and on a phone-sized frame the sidebar becomes a drawer and the betslip a bottom sheet.
The options used are all documented ones: container, launchUrl, height and event callbacks. The others are token, baseUrl, locale and title. The full list is in the SDK reference (opens in a new tab).
Cleaning up: destroy() and StrictMode double mounts
destroy() removes the iframe and every listener the SDK added. Call it on unmount, or client-side navigation will leave a live iframe (and its message listeners) behind.
In development, React StrictMode mounts, unmounts and remounts each component to surface exactly this kind of bug. With the code above, the sequence is:
- First mount starts fetching a launch URL.
- StrictMode unmounts:
cancelledbecomestrue. - Second mount starts its own fetch and mounts the iframe.
- The first fetch resolves, sees
cancelled, and does nothing.
You'll see two session requests in the network tab in development. The first launch token is never used and expires after 60 seconds, so nothing leaks. Don't try to reuse one launch URL across both mounts: it is single use, and reusing it is the bug the design prevents.
Renewing sessions with updateToken
A player session lasts 12 hours. When it ends, the embed sends session-expired with reason: "expired"; it also sends that when the player presses "Start a new session" on one of the embed's session screens. The component asks your route for a new launch URL and passes it to updateToken(), which swaps the session without reloading the iframe. Success arrives as session-updated, failure as error.
With reason: "user_blocked", stop. The player was blocked in your console; a new session would be refused anyway. Unblocking later doesn't revive old sessions, so the next page load opens a fresh one.
Reacting to events
The SDK sends ten events: loaded, ready, resize, bet-placed, session-expired, session-updated, tokens-requested, maintenance, tenant-suspended and error. Two matter most in an app.
bet-placed carries { bet_id, stake, potential_win }. If you show the player's balance in your own header, refetch it from your server here. Don't subtract the stake in the client: events are informational, and the balance lives in the API (managed mode) or in your wallet (seamless mode).
tokens-requested fires when the player presses "Get more tokens". Open your free refill. In managed mode, a refill route can credit tokens with an idempotency key that is unique per player per day, so a second click the same day is a no-op:
// app/api/sportsbook/refill/route.ts (your code, managed mode)const today = new Date().toISOString().slice(0, 10);await fetch(`https://api.thesportsbook.app/b2b/v1/users/${encodeURIComponent(user.id)}/credit`, { method: "POST", headers: { Authorization: `Bearer ${process.env.SPORTSBOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: 500, idempotency_key: `refill-${user.id}-${today}`, reason: "Daily free tokens" }),});A repeated key returns the original result with "replayed": true and moves nothing. Whatever the button opens must stay free: no prices, no purchases, no paid tiers that get more. The free token economy guide covers starting balances and refill sizes.
If your balances live in your own system, the sportsbook calls your wallet for every movement instead; the seamless wallet integration guide covers signatures, idempotency and retries.
Two more are worth wiring in production: maintenance ({ active, scope, message }) to hide your own bet prompts while betting is paused, and tenant-suspended ({ active }) to show a notice. The embed already shows both states to the player.
TypeScript types from sdk.d.ts
The types are served at https://play.thesportsbook.app/sdk.d.ts. Save the file as types/sdk.d.ts and import from it as in the component above. It exports:
TheSportsbookOptionsandTheSportsbookInstance, the options object and whatinitreturns;- one payload type per event, such as
BetPlacedPayloadandSessionExpiredPayload, collected inTheSportsbookEventMap; TheSportsbookStatic, the shape ofwindow.TheSportsbook.
It also declares window.TheSportsbook globally and adds the thesportsbook:<type> custom events to HTMLElementEventMap, which helps if you use the no-code data-thesportsbook variant somewhere else. Re-download it when you update your integration.
Common problems when you embed a sportsbook in React
Most first attempts fail in one of four places, and none of them is React-specific in a deep way.
A Content Security Policy blocks the script or the frame. If your app sends a CSP header (many Next.js starters add one in middleware.ts or next.config.js), allow https://play.thesportsbook.app in script-src for the SDK and in frame-src for the iframe. The symptom is an empty <div> and a CSP error in the console.
The launch URL is fetched too early. Prefetching the session in a layout, or in a data loader that runs on hover, can burn the 60-second token before the component mounts. Fetch it in the component's effect, as above, or right before navigation completes.
The container is too narrow. Dropping <Sportsbook> into a sidebar or a card gives you the phone layout on a desktop screen, because the layout follows the iframe's width. Put it in a full-width route.
Two components mount on one page. It works, since each call to init creates its own iframe and instance, but each also opens its own session and counts as the same player twice in your network tab. One sportsbook per page is simpler to reason about.
Before you ship
- The key is in a server-only env var, one key per environment.
user_refcomes from your session and is pseudonymous.- The session route and any page that renders a launch URL send
Cache-Control: no-store. - Every origin that embeds the sportsbook is listed in the console under Settings → Allowed origins.
- The component calls
destroy()on unmount. - You tested on a phone-width layout.
If you want the same flow without React, how to embed a sportsbook on your website walks through it with plain HTML. The embeddable sportsbook guide covers what you get beyond the code: sports, leagues, branding and plans.
You can run all of this against a real sportsbook today: start free (opens in a new tab), and nobody has to approve it. The Free plan covers one sport, up to three leagues and pre-match betting.
Frequently asked questions
Does this work with plain React, Vite or Remix, not only Next.js?
Yes. The component is plain React and only needs a URL that returns a fresh launch URL for the signed-in player. The Next.js route handler is one way to build that endpoint; an Express route, a Remix action or any server function that calls POST /b2b/v1/sessions with your API key does the same job.
Where should the API key live in a Next.js app?
In a server-only environment variable such as SPORTSBOOK_API_KEY, read inside a route handler or other server code. Never prefix it with NEXT_PUBLIC_, because Next.js inlines those variables into the browser bundle. The browser only ever sees the single-use launch URL.
Why do I see two session requests in development?
React StrictMode mounts, unmounts and remounts components in development, so the effect runs twice. The first run is cancelled before it creates an iframe, and its unused launch token expires after 60 seconds. Production builds mount once.
Can I type the SDK in TypeScript?
Yes. Type definitions are served at https://play.thesportsbook.app/sdk.d.ts. Copy the file into your project and import its types, such as TheSportsbookInstance and TheSportsbookOptions. It also declares window.TheSportsbook and the thesportsbook: DOM events.
Written by
The Sportsbook team
The people who build The Sportsbook. We have spent seven years building sportsbook software, and we write these guides from that work.
Last updated .