Skip to content

Next.js

This page shows how to embed the Expedition Insure quote widget in a Next.js app, covering both the App Router and the Pages Router. The loader is browser-only, so the central concern is mounting it after hydration and never during server rendering. For the framework-agnostic contract behind these recipes, see the embed integration guide, the events reference, and the HTTP API.

The widget renders inside an <iframe> served from https://expedition.insure. You mount it with a client-side call to window.ExpeditionInsure.mount(target, config), subscribe to lifecycle events with window.ExpeditionInsure.on(...), and let the iframe auto-resize itself. None of that can run on the server.

1. The one rule: mount in the browser only

Section titled “1. The one rule: mount in the browser only”

The loader (widget.js) reads document.currentScript, attaches a window message listener, queries the DOM for your mount target, and creates an <iframe>. All of that requires window and document. In Next.js, those APIs do not exist during server rendering, so:

  • Load widget.js and call mount() only after the component hydrates — inside a Client Component, from useEffect (which never runs on the server).
  • Never call window.ExpeditionInsure.* at module scope, in a Server Component, or in getServerSideProps / getStaticProps.
  • The global is window.ExpeditionInsure with exactly three members: mount(target, config), on(eventName, handler), and embedOrigin (a read-only string). There is no unmount() and no update() — see changing config.

Browser-only. window.ExpeditionInsure exists only after widget.js runs in the browser. Guard every call behind useEffect or a dynamic import with ssr: false.

You have two reliable ways to load widget.js in Next.js. Pick one per integration; don’t load it twice.

StrategyHowWhen to use
next/script<Script src="https://expedition.insure/widget.js" strategy="afterInteractive" onLoad={...} />App Router or Pages Router. Next controls load order; mount in onLoad.
Dynamic import() of a loader tagInject the <script> yourself inside useEffect, resolve a promise on load, then mount()When you want full control of timing, or a reusable hook.

Both end at the same place: once window.ExpeditionInsure is defined, call mount(). The loader resolves its trusted origin (embedOrigin) at runtime from the script’s own src, so the same widget.js tag works without per-environment configuration.

One listener, many mounts. The loader installs a single shared window message listener (idempotent) and supports multiple mounts on one page. Each mount() call appends a new iframe; calling it twice into the same target stacks iframes, so clear the target first if you remount.

window.ExpeditionInsure.mount(target, config); // returns the created HTMLIFrameElement

target is a CSS selector string or an HTMLElement. config requires a publishable key pk and accepts optional trip prefill and co-branding fields.

ParamTypeNotes
pkstring (required)Publishable operator key, pk_op_.... Missing or non-string ⇒ mount() throws. Public by design, like a Stripe pk_.
destinationstringTrip destination slug, e.g. "antarctica".
startDatestringISO date, e.g. "2026-12-01".
endDatestringISO date.
agesnumber[] | stringArray is serialized to CSV ([45, 47]"45,47").
residencestringResidence country code, e.g. "US".
tripCostnumber | stringTotal trip cost (whole dollars).
travelersnumber | stringTraveler count.
refstringOperator referral/tracking ref.
logoUrlstringOperator logo for co-branding.
accentColorstringBrand accent color.

mount() throws if the target selector matches nothing (mount target not found) or if pk is missing/invalid (mount requires a pk). Wrap the call in try/catch if your target may not be in the DOM yet.

Create a Client Component (note 'use client') that loads the script and mounts on hydration. Render it from any Server Component page.

app/components/ExpeditionQuote.tsx
'use client';
import { useEffect, useRef } from 'react';
import Script from 'next/script';
declare global {
interface Window {
ExpeditionInsure?: {
mount: (target: string | HTMLElement, config: Record<string, unknown>) => HTMLIFrameElement;
on: (event: string, handler: (payload: unknown) => void) => () => void;
embedOrigin: string;
};
}
}
export default function ExpeditionQuote() {
const containerRef = useRef<HTMLDivElement>(null);
function mountWidget() {
if (!window.ExpeditionInsure || !containerRef.current) return;
// Clear the target before mounting so a re-render never stacks iframes.
containerRef.current.innerHTML = '';
window.ExpeditionInsure.mount(containerRef.current, {
pk: 'pk_op_...',
destination: 'antarctica',
startDate: '2026-12-01',
endDate: '2026-12-14',
ages: [45, 47],
residence: 'US',
tripCost: 18000,
travelers: 2,
});
// Subscribe to lifecycle events. premiumCents is canonical (integer cents).
window.ExpeditionInsure.on('quote.selected', (payload) => {
console.log('selected plan', payload); // { planId, premiumCents, premium, currency }
});
}
// If the script already loaded before this component mounted, mount now.
useEffect(() => {
if (window.ExpeditionInsure) mountWidget();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Script
src="https://expedition.insure/widget.js"
strategy="afterInteractive"
onLoad={mountWidget}
/>
<div ref={containerRef} />
</>
);
}
// app/insurance/page.tsx (Server Component — fine, the widget is a client island)
import ExpeditionQuote from '../components/ExpeditionQuote';
export default function InsurancePage() {
return (
<main>
<h1>Get travel insurance</h1>
<ExpeditionQuote />
</main>
);
}

The iframe auto-resizes: the loader listens for the iframe’s height reports and sets the iframe height accordingly, so there are no inner scrollbars. You don’t manage height yourself.

Same idea — a browser-only mount — but you can also avoid SSR entirely with next/dynamic and ssr: false. That guarantees the component never renders on the server, which is the simplest guard against window/document access.

components/ExpeditionQuoteClient.tsx
import { useEffect, useRef } from 'react';
export default function ExpeditionQuoteClient() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function mountWidget() {
if (!window.ExpeditionInsure || !containerRef.current) return;
containerRef.current.innerHTML = '';
window.ExpeditionInsure.mount(containerRef.current, {
pk: 'pk_op_...',
destination: 'antarctica',
travelers: 2,
residence: 'US',
tripCost: 18000,
});
window.ExpeditionInsure.on('quote.ready', (payload) => {
console.log('quote ready', payload); // { quoteId, plansCount }
});
}
// Inject the loader once, then mount when it's available.
const existing = document.querySelector<HTMLScriptElement>('script[data-ei-loader]');
if (existing && window.ExpeditionInsure) {
mountWidget();
return;
}
const script = document.createElement('script');
script.src = 'https://expedition.insure/widget.js';
script.async = true;
script.dataset.eiLoader = 'true';
script.addEventListener('load', mountWidget);
document.body.appendChild(script);
}, []);
return <div ref={containerRef} />;
}
pages/insurance.tsx
import dynamic from 'next/dynamic';
// ssr: false keeps the widget out of server rendering entirely.
const ExpeditionQuote = dynamic(() => import('../components/ExpeditionQuoteClient'), {
ssr: false,
});
export default function InsurancePage() {
return (
<main>
<h1>Get travel insurance</h1>
<ExpeditionQuote />
</main>
);
}

getServerSideProps and getStaticProps run on the server — never reference window.ExpeditionInsure there. Use them only to fetch prefill values (destination, dates, trip cost) and pass them as props into the client component’s mount() config.

There is no update() or unmount() on the global. To change mount() config after the fact — for example when the user edits trip dates in your own form — remount: clear the target element, then call mount() again with the new config.

function remount(container: HTMLDivElement, config: Record<string, unknown>) {
container.innerHTML = ''; // drop the old iframe
window.ExpeditionInsure!.mount(container, config);
}

The shared message listener is reused across remounts, so this is cheap. Just always clear the target first, or iframes stack.

Next.js client navigation does not reload the page, so handle the widget’s lifecycle in React, not in global scripts:

  • Mounting on navigation. Because you mount inside useEffect (App Router Client Component) or a dynamic(..., { ssr: false }) component (Pages Router), the mount runs each time the component mounts — including after a client-side route change to the page that hosts it. No extra wiring needed.
  • Re-entering a page. If a user navigates away and back, the component remounts and mount() runs again into a fresh container. Since you clear the target (innerHTML = '') before mounting, you won’t accumulate iframes.
  • Don’t mount at the app shell level. Keep the widget inside the route component (the page or a component it renders), not in layout.tsx / _app.tsx, so its lifecycle follows the route.

Subscribe with window.ExpeditionInsure.on(name, handler); it returns an unsubscribe function. A throwing handler is isolated (logged, never breaks the bridge or other listeners). Unknown event names throw.

EventPayloadFires
quote.ready{ quoteId, plansCount }Quote loaded in the iframe.
quote.selected{ planId, premiumCents, premium, currency }Traveler picks a plan.
quote.error{ code, message }Quote could not load.
payment.succeeded{ quoteId, planId, premiumCents, premium, currency }Embedded-checkout payment UI reports success.
payment.failed{ code, message }Embedded-checkout payment UI reports failure.

Read premiumCents. premiumCents is an integer in minor units (cents) — divide by 100 for display.

payment.succeeded is a UX signal only. It tells your UI the checkout returned successfully and can be missed (a closed tab, a dropped frame). It is never the source of truth for a sale. The authoritative post-sale signal is the server-side policy.issued webhook — reconcile fulfillment and commissions on that, not on payment.succeeded. See the events reference and webhooks.

  • ReferenceError: window is not defined at build/SSR. You referenced window.ExpeditionInsure outside useEffect/a client-only component. Move the call into useEffect, or use dynamic(..., { ssr: false }).
  • mount target not found. The container isn’t in the DOM when you call mount(). Mount against a ref’d element from useEffect, after render.
  • Stacked iframes after navigation or re-render. Clear the target (innerHTML = '') before each mount().
  • Nothing renders, no error. Confirm the script actually loaded (window.ExpeditionInsure is defined) and that your pk_op_... key is correct. The publishable key is public, but requests are gated server-side by your origin allowlist — make sure your site’s origin is allowlisted for the key.