Install and troubleshooting
The embed is plain HTML, so anywhere you can paste a script tag will work. What differs between platforms is where that box lives and which one of them quietly does something unhelpful. Those are below.
Per-platform
| Platform | Where the snippet goes | The thing that catches people out |
|---|---|---|
| WordPress | Block editor: add a Custom HTML block and paste the snippet. Classic editor: switch to the Text tab first. | Popup and slider builders (Elementor popups, Divi modals, most accordion plugins) insert their markup after the page has loaded, so the widget's initial scan never sees it. Call OddsRelay.scan() when the popup opens. |
| Webflow | Drop an Embed element where the widget should sit and paste the snippet. | Embeds do not run in the Designer canvas. Publish, then check the published site. |
| Squarespace | Insert a Code block and paste the snippet as HTML. | Leave "Display Source" unchecked, or the page prints the snippet as text instead of running it. |
| Wix | Add an Embed HTML element (Embed a widget) and paste the snippet. | Wix's HTML element sandboxes into an iframe of a fixed height, so set the element's height to fit the widget. Custom Code in site settings avoids the iframe entirely and is the better placement for a full-width board. |
| Shopify | A Custom Liquid section on the template you want it on, or theme.liquid for every page. | theme.liquid loads the script on every page including checkout. Prefer a section unless you genuinely want it site-wide. |
| React / Next.js | Render the host div, load the script once, then mount in an effect and tear down on unmount. | Client-side navigation does not re-run the initial scan. Either call OddsRelay.scan() after a route change or mount explicitly against a ref, and always call destroy() in the effect cleanup so a remount does not stack two widgets in one element. |
| Vue / Nuxt | Same shape: a ref'd host element, mount in onMounted, destroy in onBeforeUnmount. | Under <KeepAlive>, onMounted does not fire again on reactivation, so mount in onActivated too, or keep the instance alive. |
Single-page apps and dynamic content
The bundle scans the document for host elements once, when it loads. Anything added afterwards (a client-side route change, a tab that renders on click, a popup builder, a lazily loaded section) has not been seen and will not mount by itself.
// After a client-side navigation, host elements that were not on the page
// when the bundle loaded have not been seen. One call fixes it.
window.OddsRelay?.scan();In a component framework, mounting explicitly against a ref is cleaner than scanning, and it gives you the instance handle you need to tear down on unmount.
import { useEffect, useRef } from "react";
const SRC = "https://oddsrelay.io/widgets/v1/oddsrelay-widgets.js";
function loadBundle() {
if (window.OddsRelay) return Promise.resolve();
const existing = document.querySelector('script[src="' + SRC + '"]');
if (existing) return new Promise((r) => existing.addEventListener("load", r));
const s = document.createElement("script");
s.src = SRC;
s.crossOrigin = "anonymous";
s.async = true;
const done = new Promise((r) => s.addEventListener("load", r));
document.head.appendChild(s);
return done;
}
export function MatchedCalculator() {
const host = useRef(null);
useEffect(() => {
let instance;
let cancelled = false;
loadBundle().then(() => {
if (cancelled || !host.current) return;
instance = window.OddsRelay.mount(host.current, {
widget: "standard",
theme: "midnight",
});
});
// Tearing down is what stops a remount stacking two widgets in one element.
return () => {
cancelled = true;
instance?.destroy();
};
}, []);
return <div ref={host} style={{ minHeight: 420 }} />;
}Always destroy on unmount
React's strict mode mounts effects twice in development, and most routers unmount and remount on navigation. Without the destroy()in the cleanup you get two widgets stacked in one element, which is the second most common install report after "does not render after navigation".
Troubleshooting matrix
Indexed by what you can see, because when a widget is broken on a page you did not build, the symptom is all you have.
| Symptom | Console | Cause and fix |
|---|---|---|
| Renders on a full page load, but not after clicking through your own site (SPA / client-side routing). | (nothing) | The bundle scans for host elements once, when the document is ready. An element that appears later is never seen.Call window.OddsRelay.scan() after the navigation completes, or mount explicitly against the element ref in your framework's mount hook. |
| The board shows labelled sample data instead of live odds. | (a CORS failure, or nothing) | Three possibilities, in order of likelihood: no data-or-key on the element; the page's origin is not on the key's allowlist; or the key's quota is exhausted.Check the key attribute first, then send us the exact origin (scheme + host + port) to add. Origins are matched exactly, so https://www.example.com and https://example.com are two entries. |
| Nothing renders and there is no console error at all. | (nothing) | A consent manager rewrote the script tag to type="text/plain", so the browser never executed it. This looks identical to a broken widget.Categorise the script in your consent manager. Check the page source for type="text/plain" on our tag to confirm. |
| The widget renders twice. | (nothing) | The script tag is on the page twice, or a framework re-mounted the component without destroying the previous instance.Include the script once per page. In React or Vue, return instance.destroy() from the effect cleanup. |
| The money figures show the wrong currency symbol. | (nothing) | Currency is derived from the region, not set separately.Set data-or-region to the market you are serving. data-or-currency forces a symbol if you genuinely need to override. |
| The type looks wrong: different family, different size, cramped or loose. | (nothing) | data-or-font defaults to host, which inherits your page's font on purpose so the widget matches your site.That is usually what you want. Set data-or-font="sans" (or serif / mono) to pin a stack instead. Number columns align best on a stack with tabular figures. |
| Layout shifts as the widget appears, and your Lighthouse run flags CLS against our script. | (nothing) | The host element has no height until the widget mounts into it.Give the host div a min-height in your own CSS matching the widget's resting height. The snippets on each widget page carry a sensible one. |
Console messages that name a Content-Security-Policy directive are covered in more detail on the CSP page. A widget that renders nothing with no console message at all is most often a consent manager.