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.jsand callmount()only after the component hydrates — inside a Client Component, fromuseEffect(which never runs on the server). - Never call
window.ExpeditionInsure.*at module scope, in a Server Component, or ingetServerSideProps/getStaticProps. - The global is
window.ExpeditionInsurewith exactly three members:mount(target, config),on(eventName, handler), andembedOrigin(a read-only string). There is nounmount()and noupdate()— see changing config.
Browser-only.
window.ExpeditionInsureexists only afterwidget.jsruns in the browser. Guard every call behinduseEffector a dynamic import withssr: false.
2. Script include strategies
Section titled “2. Script include strategies”You have two reliable ways to load widget.js in Next.js. Pick one per integration; don’t load it twice.
| Strategy | How | When 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 tag | Inject 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
windowmessagelistener (idempotent) and supports multiple mounts on one page. Eachmount()call appends a new iframe; calling it twice into the same target stacks iframes, so clear the target first if you remount.
3. mount() and config
Section titled “3. mount() and config”window.ExpeditionInsure.mount(target, config); // returns the created HTMLIFrameElementtarget is a CSS selector string or an HTMLElement. config requires a publishable key pk and accepts optional trip prefill and co-branding fields.
| Param | Type | Notes |
|---|---|---|
pk | string (required) | Publishable operator key, pk_op_.... Missing or non-string ⇒ mount() throws. Public by design, like a Stripe pk_. |
destination | string | Trip destination slug, e.g. "antarctica". |
startDate | string | ISO date, e.g. "2026-12-01". |
endDate | string | ISO date. |
ages | number[] | string | Array is serialized to CSV ([45, 47] → "45,47"). |
residence | string | Residence country code, e.g. "US". |
tripCost | number | string | Total trip cost (whole dollars). |
travelers | number | string | Traveler count. |
ref | string | Operator referral/tracking ref. |
logoUrl | string | Operator logo for co-branding. |
accentColor | string | Brand 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.
4. App Router recipe
Section titled “4. App Router recipe”Create a Client Component (note 'use client') that loads the script and mounts on hydration. Render it from any Server Component page.
'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.
5. Pages Router recipe
Section titled “5. Pages Router recipe”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.
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} />;}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.
6. Changing config (remount)
Section titled “6. Changing config (remount)”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.
7. SPA route changes
Section titled “7. SPA route changes”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 adynamic(..., { 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.
8. Events you can subscribe to
Section titled “8. Events you can subscribe to”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.
| Event | Payload | Fires |
|---|---|---|
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.premiumCentsis an integer in minor units (cents) — divide by 100 for display.
payment.succeededis 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-sidepolicy.issuedwebhook — reconcile fulfillment and commissions on that, not onpayment.succeeded. See the events reference and webhooks.
9. Common pitfalls
Section titled “9. Common pitfalls”ReferenceError: window is not definedat build/SSR. You referencedwindow.ExpeditionInsureoutsideuseEffect/a client-only component. Move the call intouseEffect, or usedynamic(..., { ssr: false }).mount target not found. The container isn’t in the DOM when you callmount(). Mount against aref’d element fromuseEffect, after render.- Stacked iframes after navigation or re-render. Clear the target (
innerHTML = '') before eachmount(). - Nothing renders, no error. Confirm the script actually loaded (
window.ExpeditionInsureis defined) and that yourpk_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.