These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Integration — Next.js → External widget

The Next.js dashboard and the external widget codebase are two separate deployments that communicate through two distinct channels: a snippet serving proxy that lets the dashboard serve or vet the widget’s built JavaScript, and a LivePreview postMessage bridge that keeps the Finder Builder’s side panel in sync with an iframe embedding the live widget.


1. Snippet serving proxy

Route

src/app/api/ext/v2/snippet/route.ts — a Next.js Route Handler that responds to GET /api/ext/v2/snippet.

What it does

The route tries to serve the widget script from three sources in priority order:

  1. Local dev build (../dropafinder-app-external/ext/2/snippet.dev.js) — generated by running npm run build:dev inside dropafinder-app-external/ext/2/source. This build points the widget’s API calls at the local Next.js dev server rather than production endpoints.
  2. Local production build (../dropafinder-app-external/ext/2/snippet.js) — the standard production-targeted build sitting in the sibling repo directory. Used in development when the dev build is absent.
  3. CDN fallback (https://cdn.locationfinders.com/snippet.js) — fetched and proxied when neither local file exists (always the path taken in production).

The candidate list is only populated in development; in production the array is empty and the handler always falls through to the CDN proxy (route.ts:9–12).

Both local-file responses and the CDN proxy response are served with Content-Type: application/javascript and Cache-Control: no-cache (route.ts:17–19, route.ts:39–42). A 502 is returned if the CDN fetch fails (route.ts:31–34).

Why the proxy exists

Two reasons are stated explicitly in the finder-preview page that loads the script (finder-preview/page.tsx:122–125):

  1. Avoids browser CORS restrictions — the widget script is served same-origin from the Next.js app rather than directly from the CDN.
  2. Lets the server vet or vendor the file — the dashboard controls what script reaches the browser, making it straightforward to swap in a pinned local build during development without touching CSP headers or browser extension restrictions.

Preview page wiring

The snippet is loaded dynamically, not via a <script> tag in the document head. The finder-preview page (src/app/(preview)/finder-preview/page.tsx:119–126) creates a <script type="module" id="lf-snippet"> element at runtime and sets its src to /api/ext/v2/snippet. The script element is cleaned up when the component unmounts (page.tsx:134).

⚠️ Warning: Only one lf-snippet element is active at a time. The page removes any existing lf-snippet element before inserting a fresh one (page.tsx:70–71). Re-running the effect with different params (e.g. a changed token or previewSession) will reload the widget from scratch.

Dev workflow

To use a locally modified widget in the builder:

  1. In dropafinder-app-external/ext/2/source, run npm run build:dev to emit snippet.dev.js to the sibling path.
  2. Restart (or leave running) the Next.js dev server — no config changes needed.
  3. Navigate to the Finder Builder; the proxy will serve the dev build automatically.

💡 Tip: If snippet.dev.js is absent but snippet.js is present, the proxy falls back to snippet.js silently. Delete or rename snippet.js if you need to confirm the dev build is being served.


2. LivePreview postMessage bridge

Architecture overview

The Finder Builder (src/components/finders/v2/FinderBuilderV2.tsx) renders a two-panel layout: a scrollable editor panel on the left and a <LivePreview> panel on the right (FinderBuilderV2.tsx:782–789). LivePreview embeds the finder-preview page in an <iframe>, and the finder-preview page loads the widget script via the proxy described above.

State changes in the builder flow outward through this chain:

historyReducer dispatch → draftPreviewPayload (useMemo) → LivePreview.draftPayload prop → postMessage('lf-preview:update', ...) into iframe → finder-preview page: window.addEventListener('message', ...) → dispatchEvent(new CustomEvent('lf-preview-payload', ...)) → App.svelte: window.addEventListener('lf-preview-payload', ...) → applyPayload(detail.payload, { isPreview: true, preserveUiState: true })

LivePreview component

File: src/components/finders/v2/LivePreview.tsx

Props:

PropTypePurpose
tokenstringThe finder’s public token; passed to the preview page as ?token= so the widget can fetch the published payload as a fallback.
previewSessionstring | undefinedA UUID generated once per builder session (FinderBuilderV2.tsx:209–213). Used to correlate messages — the iframe and the builder must agree on this value.
draftPayloadRecord<string, unknown> | nullThe full preview payload built by buildFinderPreviewPayload. Changing this prop triggers a debounced postMessage.

Exported handle:

LivePreviewHandle.reload() — reassigns iframeRef.current.src to force a full reload. Called by the builder on certain destructive actions (FinderBuilderV2.tsx:485).

iframe src: /finder-preview?token=<token>&previewSession=<uuid> (LivePreview.tsx:183).

Preview session UUID

FinderBuilderV2 generates the previewSession once at mount using crypto.randomUUID() (with a Date.now() + Math.random() fallback) and holds it in a ref (FinderBuilderV2.tsx:209–213). The same value is passed to LivePreview as previewSession and embedded in every postMessage payload. The iframe page and App.svelte both use it to ignore messages from other builder tabs or stale sessions.

postMessage protocol

Sender: LivePreview.postPreviewMessage (LivePreview.tsx:102–118)

Target origin: window.location.origin — messages are always same-origin (LivePreview.tsx:114).

Message shape:

{ type: 'lf-preview:init' | 'lf-preview:update', previewSession: string, // UUID matching the session payload: Record<string, unknown>, // full finder preview payload }

Two message types:

  • lf-preview:init — sent 60 ms after the iframe onLoad event fires (LivePreview.tsx:278–280, LivePreview.tsx:296–298). Bootstraps the widget with the current draft immediately after the page finishes loading.
  • lf-preview:update — sent (debounced 160 ms) whenever draftPayload changes (LivePreview.tsx:139–144). Delivers incremental state changes as the user edits.

Debounce and focus preservation:

The useEffect that drives lf-preview:update messages sets a 160 ms setTimeout on each draftPayload change, cancelling any pending timeout first (LivePreview.tsx:135–137). Immediately before posting, the component snapshots any focused <input> or <textarea> in the builder and restores focus after the next animation frame (LivePreview.tsx:141–143). This prevents the preview update from stealing keyboard focus away from the user’s active field.

SessionStorage persistence

Before posting any message, LivePreview writes the payload to sessionStorage under the key lf-preview:<previewSession> (LivePreview.tsx:91–100). The finder-preview page reads this key on mount (page.tsx:59–66) so that a page refresh or a full widget reload can hydrate from the last-known draft without waiting for the builder to re-send.

Message receiver — finder-preview page

File: src/app/(preview)/finder-preview/page.tsx

The page registers window.addEventListener('message', handlePreviewMessage) (page.tsx:117). The handler:

  1. Origin check — rejects any message where event.origin !== window.location.origin (page.tsx:83–85).
  2. Shape check — requires data.type, data.previewSession, and data.payload to be present (page.tsx:88–96).
  3. Type and session check — accepts only lf-preview:init and lf-preview:update; rejects if data.previewSession does not match the previewSession query param (page.tsx:99–104).
  4. SessionStorage write — persists the payload so reloads can recover state (page.tsx:106–112).
  5. Widget dispatch — sets window.__LF_PREVIEW_MODE = true, window.__LF_PREVIEW_SESSION, window.__LF_PREVIEW_PAYLOAD, then fires window.dispatchEvent(new CustomEvent('lf-preview-payload', ...)) (page.tsx:40–47).

Message receiver — widget (App.svelte)

File: dropafinder-app-external/ext/2/source/src/App.svelte

Inside onMount, App.svelte registers window.addEventListener('lf-preview-payload', handlePreviewPayload) (App.svelte:226). This is a DOM custom event, not a postMessage event — the finder-preview page acts as a translation layer between the cross-frame postMessage and the in-page custom event.

The handler (App.svelte:203–224):

  1. Skips the event if detail.payload is absent.
  2. Rejects the event if the incoming detail.previewSession does not match the local previewSession variable (guards against stale messages during hot module replacement in dev, or when multiple widgets are somehow mounted).
  3. Sets previewMode = true and updates previewSession.
  4. Calls applyPayload(detail.payload, { isPreview: true, preserveUiState: hasAppliedPayload }).
    • preserveUiState: true (passed on subsequent updates once the widget has already applied a payload) tells initializeFinderStores to keep the user’s current search query, active location, and scroll position rather than resetting them.

On unmount, the listener is removed (App.svelte:257).

Boot-time preview hydration

When App.svelte first mounts, getPreviewBridgeState() (App.svelte:42–54) checks three sources:

  1. window.__LF_PREVIEW_MODE / window.__LF_PREVIEW_SESSION — set by the finder-preview page before the snippet loads.
  2. params.get('previewSession') — URL query param as a fallback.
  3. window.__LF_PREVIEW_PAYLOAD — the payload written by the finder-preview page from sessionStorage.

If a payload is found on window.__LF_PREVIEW_PAYLOAD, the widget renders immediately from that data without making a CDN fetch (App.svelte:236–238). If no payload is found but finderKey is set, the widget fetches the published payload from the CDN as normal and applies it with isPreview: previewMode (App.svelte:245–246).

Security model

The postMessage bridge has two layers of origin enforcement:

  1. Sender restrictionLivePreview passes window.location.origin (not '*') as the targetOrigin argument to postMessage (LivePreview.tsx:114). The browser will only deliver the message if the iframe’s origin matches; any cross-origin iframe would silently drop it.
  2. Receiver check — the handlePreviewMessage handler in the finder-preview page rejects any event where event.origin !== window.location.origin (page.tsx:83–85). This prevents a malicious page from injecting a crafted payload by posting to the preview window directly.

Together these ensure the preview payload can only flow from a same-origin builder page to a same-origin preview page, and only to the specific preview session that the builder started.

⚠️ Warning: The previewSession UUID is not a secret — it is visible in the iframe URL and in every postMessage. Its role is session correlation, not authentication. Access control relies entirely on same-origin enforcement.


Sequence diagram