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:
- Local dev build (
../dropafinder-app-external/ext/2/snippet.dev.js) — generated by runningnpm run build:devinsidedropafinder-app-external/ext/2/source. This build points the widget’s API calls at the local Next.js dev server rather than production endpoints. - 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. - 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):
- Avoids browser CORS restrictions — the widget script is served same-origin from the Next.js app rather than directly from the CDN.
- 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-snippetelement is active at a time. The page removes any existinglf-snippetelement before inserting a fresh one (page.tsx:70–71). Re-running the effect with different params (e.g. a changedtokenorpreviewSession) will reload the widget from scratch.
Dev workflow
To use a locally modified widget in the builder:
- In
dropafinder-app-external/ext/2/source, runnpm run build:devto emitsnippet.dev.jsto the sibling path. - Restart (or leave running) the Next.js dev server — no config changes needed.
- Navigate to the Finder Builder; the proxy will serve the dev build automatically.
💡 Tip: If
snippet.dev.jsis absent butsnippet.jsis present, the proxy falls back tosnippet.jssilently. Delete or renamesnippet.jsif 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:
| Prop | Type | Purpose |
|---|---|---|
token | string | The finder’s public token; passed to the preview page as ?token= so the widget can fetch the published payload as a fallback. |
previewSession | string | undefined | A UUID generated once per builder session (FinderBuilderV2.tsx:209–213). Used to correlate messages — the iframe and the builder must agree on this value. |
draftPayload | Record<string, unknown> | null | The 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 iframeonLoadevent 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) wheneverdraftPayloadchanges (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:
- Origin check — rejects any message where
event.origin !== window.location.origin(page.tsx:83–85). - Shape check — requires
data.type,data.previewSession, anddata.payloadto be present (page.tsx:88–96). - Type and session check — accepts only
lf-preview:initandlf-preview:update; rejects ifdata.previewSessiondoes not match thepreviewSessionquery param (page.tsx:99–104). - SessionStorage write — persists the payload so reloads can recover state (
page.tsx:106–112). - Widget dispatch — sets
window.__LF_PREVIEW_MODE = true,window.__LF_PREVIEW_SESSION,window.__LF_PREVIEW_PAYLOAD, then fireswindow.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):
- Skips the event if
detail.payloadis absent. - Rejects the event if the incoming
detail.previewSessiondoes not match the localpreviewSessionvariable (guards against stale messages during hot module replacement in dev, or when multiple widgets are somehow mounted). - Sets
previewMode = trueand updatespreviewSession. - Calls
applyPayload(detail.payload, { isPreview: true, preserveUiState: hasAppliedPayload }).preserveUiState: true(passed on subsequent updates once the widget has already applied a payload) tellsinitializeFinderStoresto 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:
window.__LF_PREVIEW_MODE/window.__LF_PREVIEW_SESSION— set by the finder-preview page before the snippet loads.params.get('previewSession')— URL query param as a fallback.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:
- Sender restriction —
LivePreviewpasseswindow.location.origin(not'*') as thetargetOriginargument topostMessage(LivePreview.tsx:114). The browser will only deliver the message if the iframe’s origin matches; any cross-origin iframe would silently drop it. - Receiver check — the
handlePreviewMessagehandler in the finder-preview page rejects any event whereevent.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
previewSessionUUID 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
Related pages
- Integration points index
- Finder Builder V2 — component and state (session 6 ★)
- External widget overview (session 6 ★)