Install and troubleshooting
Paste the snippet wherever your platform accepts HTML. Below: where that is on each platform, and fixes for common problems.
Platforms
| Platform | Where it goes | Watch out for |
|---|---|---|
| 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 window.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 want it on every page. |
| 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 call destroy() in the cleanup. React's strict mode runs effects twice in development and most routers remount on navigation; without it you get two widgets in one element.
Troubleshooting
Find what you see, then the fix.
| What you see | Cause and fix |
|---|---|
| Only a "Powered by OddsRelay" link shows where the widget should be. | The link inside the snippet's div is plain HTML, so it shows before the script runs; the widget then places it in its own footer. If it stays a bare link, the script never ran: blocked by a Content-Security-Policy or a consent manager, or the script tag is missing. Check the script tag is on the page once, then the CSP and consent sections of these docs. The widget picks the link up when it mounts. |
| Renders on a full page load, but not after clicking through your own site (SPA / client-side routing). | 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. | Most often: no data-or-key on the element; then the page's origin missing from the key's allowlist; then the key out of tokens. 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. | 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. | 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. | 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 to override it. |
| The type looks wrong: different family, different size, cramped or loose. | 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. | 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 CSP directive: see Content-Security-Policy. No console message at all usually means a consent manager.