Skip to content

Embed a sportsbook in a React or Next.js app

Embed a sportsbook in React or Next.js: a route handler that opens sessions, a component around the JS SDK, cleanup and events. Play-for-fun, no real money.

Updated 7 min readBy The Sportsbook team

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

Architecture: a route handler and a client component

The flow has the same three steps as any sportsbook iframe integration, mapped onto Next.js:

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.

TypeScript
// 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:

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.

TSX
// 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 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:

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:

TypeScript
// 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:

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

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 .

Put a sportsbook on your website. Start on Free today.

One sport, pre-match, your brand, played with free tokens your players earn. Two lines of HTML and one API call.

Free plan · nothing to pay, no approval. For businesses and creators, 18+.