← Back to writingEngineering / 2026.06

60 Review Widgets, 21 Families, One Registry: Retiring a FreeMarker Renderer

Embeddable widget architecture: a stable iframe contract, a unified widget route, and family-based rendering behind one API shape.

Klantenvertellen and Kiyoh customers - bakeries, notaries, accountants, movers, clinics - embed a small badge on their own website showing their star rating and review count. That badge is an iframe. Behind it sat a Java/Spring backend rendering FreeMarker templates, one .ftl file per widget variant. bakkerscore.ftl. notarisscore-v2.ftl. accountantscore-v2.ftl. Dozens of them, each a standalone document with its own inline styles and a dedicated JS bundle.

I rebuilt the renderer in Next.js 16. Not a rewrite of the whole platform - the portal still owns the data, the review scores, the business rules. Only the rendering moved. This is what that migration looked like and the patterns that made it tractable.

The iframe was already the migration boundary

An iframe's src is just a URL. The legacy system served widgets from a Spring MVC controller. The new system serves the same URL shape from a Next.js app. Moving a widget is: point its embed URL at kv-widgets instead of the portal FTL host. If something breaks, point it back. No feature flags, no gradual rollout logic inside the app, no coordination between old and new beyond DNS.

That meant I could migrate one widget family at a time over months, with the legacy system and the new one never needing to know about each other. Both consume the same JSON contract from the portal - widget-internal still returns the same WidgetModel shape the FreeMarker templates always read. No new backend API required for cutover, which is the detail that made the whole thing low-risk enough to actually ship incrementally.

Before building migration infrastructure, look for a boundary that already exists. Iframes, API gateways, DNS - sometimes the seam is already cut and the job is just not disturbing it.

60 keys were 21 families

The FTL system had a file per widget key because that's what a template language gives you. When I actually read them, most weren't different designs - they were the same layout with a different SVG background, a different orientation, a different seal graphic. A dozen "score v2" widgets shared one skeleton. The advocaat widgets were the same bar, flipped.

The inventory came down to a single map, WIDGET_FAMILY_BY_KEY:

export const WIDGET_FAMILY_BY_KEY = { bakkerscore: "score_v2", "notarisscore-v2": "score_v2", "accountantscore-v2": "score_v2", // ... 9 more score_v2 entries notarisscore: "classic_score_css", accountantscore: "classic_score_css", // ... vaco: "legacy_branded_inline", vhg2: "legacy_branded_inline", // ... } as const satisfies Record<string, WidgetFamily>; export type MigratedWidgetKey = keyof typeof WIDGET_FAMILY_BY_KEY; export function isMigratedWidget(widget: string): widget is MigratedWidgetKey { return Object.prototype.hasOwnProperty.call(WIDGET_FAMILY_BY_KEY, widget); }

as const satisfies Record<string, WidgetFamily> is doing two jobs at once. as const locks the object so keyof typeof produces the exact 60-member union of every real widget key, not just string. satisfies still checks every value against the WidgetFamily union at authoring time, so a typo'd family name is a compile error, not a runtime surprise three widgets later.

60 keys landed in 21 families. Every other piece of type safety in the app - route validation, the render dispatcher, layout presets - derives from this one object. Nothing else hand-maintains a list of widget keys.

One map decides the family. Everything downstream - routing, rendering, layout - reads from it instead of hardcoding widget names.

The production route calls isMigratedWidget() and returns a 404 for anything not yet in the map. Widgets still served by the old FTL host simply don't resolve here - which is exactly the behavior you want mid-cutover, where "not migrated yet" and "doesn't exist" need to look the same to a router that only owns half the widgets.

When you inherit a system where the file count is large but the concept count is small, build the taxonomy first. Name the families. The file count stops being the thing you're fighting.

The registry dispatch is two tiers, not one

Most families render through one shared component. A few widgets need their own component even though they technically belong to a shared family - vaco, vhg2, and thuiswinkel_org all sit in legacy_branded_inline but look nothing alike once you actually open the FTL they came from. Forcing them through one generic renderer would have meant a component full of if (widget === "vaco") branches.

Instead the registry checks a per-key override map first, then falls through to the per-family map:

const WIDGET_KEY_RENDERERS: Partial<Record<MigratedWidgetKey, WidgetRenderer>> = { vaco: dynamic(() => import("./VacoWidget")), vhg2: dynamic(() => import("./Vhg2Widget")), thuiswinkel_org: dynamic(() => import("./thuiswinkel/ThuiswinkelWidget")), }; const FAMILY_RENDERERS: Record<WidgetFamily, WidgetRenderer> = { score_v2: dynamic(() => import("./ScoreV2Widget")), classic_score_css: dynamic(() => import("./ClassicScoreWidget")), legacy_branded_inline: dynamic(() => import("./LegacyBrandedWidget")), // ... 18 more }; export function renderWidget(widget: MigratedWidgetKey, model: WidgetModel) { const Renderer = WIDGET_KEY_RENDERERS[widget] ?? FAMILY_RENDERERS[WIDGET_FAMILY_BY_KEY[widget]]; return <Renderer model={model} />; }

Three widgets need a per-key override. The other 57 resolve through the family map - the same dispatcher handles both without branching on widget name.

Every entry is next/dynamic, so a merchant's iframe requesting bakkerscore only downloads the score_v2 bundle - not all 39 widget components sitting in the codebase. That matters more here than in most apps: this JS is loading inside a third-party page the operator doesn't control, next to whatever else is on that page.

One scale function solves two different problems

The hardest part of this project wasn't React or TypeScript. It was that every widget renders inside an iframe whose dimensions are set by the merchant's website, and the merchant's website is not something I get to negotiate with.

The FTL system handled this with per-widget JavaScript that measured the viewport and manually repositioned elements - different code in every widget's dedicated bundle. The new system authors every widget at a fixed design resolution (407×434 for the compact score widgets, 739×787 for the large review layout, 420×340 for Kiyoh) and lets one function handle whatever the iframe actually turns out to be:

export function computeContainScale( originalWidth: number, originalHeight: number, viewportWidth: number, viewportHeight: number, ): ContainScaleResult { const aspectRatio = originalWidth / originalHeight; let newWidth: number; let newHeight: number; if (viewportHeight * aspectRatio <= viewportWidth) { newHeight = viewportHeight; newWidth = viewportHeight * aspectRatio; } else { newWidth = viewportWidth; newHeight = viewportWidth / aspectRatio; } const scale = Math.min(viewportWidth / newWidth, viewportHeight / newHeight); const scaleWidth = newWidth / originalWidth; const scaleHeight = newHeight / originalHeight; const scaleCalc = Math.max(scaleWidth, scaleHeight); return { newWidth, newHeight, scale, scaleCalc }; }

This is object-fit: contain, done in JS, but it returns two different numbers for two different jobs. scale is a CSS transform: scale() applied to a box already sized in design pixels - it letterboxes the widget to fit whatever the iframe is. scaleCalc is a content-scale factor fed into every widget's internal pixel math, because most of these widgets carry absolute-positioned coordinates ported straight out of the old FTL/CSS - left: 31px, font-size: 202px - and those need to grow and shrink with the container, not just get cropped.

ContainerWidget is the runtime piece: a client component that watches its own wrapper with a ResizeObserver, runs computeContainScale on every resize, and hands the result down through React context. A small helper turns that into something widget authors actually write:

export function createDesignScaler(scaleCalc: number) { const px = (designUnits: number) => `${designUnits * scaleCalc}px`; return { px, scaleCalc } as const; }
const ds = createDesignScaler(scaleCalc); <span style={{ position: "absolute", left: ds.px(31), top: ds.px(2), fontSize: ds.px(202) }}> {score} </span>

Those numbers come directly from the design file. ds.px() converts them to whatever the iframe actually is. The widget component never touches viewport math - it just describes a fixed-size design and trusts the container to make it fit.

One measurement, two scale factors: scale letterboxes the box, scaleCalc rescales the design-pixel coordinates inside it.

One family needed a variant on this: advocaat_slide uses a stageJustify: "end" option so the panel sits flush against the iframe's right edge instead of floating centered with a gap - a detail I only found by comparing pixel-for-pixel against how the legacy widget actually behaved, not from any spec.

Separate the resolution you author at from the resolution you're actually given. Author fixed, scale uniformly at the container boundary, and give child components a conversion function instead of viewport awareness.

Grading my own type guards on the way down

The dispatcher above assumes renderWidget already knows it has a MigratedWidgetKey. Getting there is a chain of narrowing, not one big check:

export const MIGRATED_WIDGETS = Object.keys(WIDGET_FAMILY_BY_KEY).sort() as MigratedWidgetKey[]; switch (family) { case "score_v2": if (!isScoreV2Widget(widget)) return null; return <ScoreV2Widget model={model} svgName={getScoreV2SvgName(widget)} />; }

isScoreV2Widget is its own tiny registry one level down - a second as const map, SCORE_V2_WIDGET_REGISTRY, pairing each of the 12 score-v2 widgets with its SVG basename and a compact | reviews display variant, with its own type guard derived the same way the top-level one is. After the guard, TypeScript knows widget is a ScoreV2WidgetKey, so getScoreV2SvgName(widget) is valid without a cast. No any, no class hierarchy, just maps and guard functions nested one layer where a family actually needed per-widget metadata.

The QA console that replaced staging comparisons

The FTL system had no way to preview a widget without hitting the real backend with real location data. Comparing old-vs-new for a given widget meant finding a live merchant using it and squinting at two browser tabs.

The /test route is an internal console: pick any of the 60 widget keys, switch layout mode, type a width and height and watch it snap to whichever preset mode is closest, toggle color/language/CTA/transparency, and strip the console chrome entirely with ?view=widget to get a clean iframe-parity screenshot. Every migrated widget also gets a mock-data demo route - /demo/bakkerscore?score=8.7&reviews=42&color=dark - so a widget can be reviewed before the portal even returns real data for it.

Every migrated widget, mock data, no portal connection required. ?view=widget strips the console chrome for a clean side-by-side screenshot against the legacy version.

Building the QA harness felt like a detour from "actually migrating widgets" the first week. It paid for itself by the third family, once side-by-side pixel comparison against the legacy version stopped being optional and started being the only way to catch a rounding difference in computeContainScale before a merchant did.

The commit hook is the CI

There's no GitHub Actions workflow in this repo. There doesn't need to be one, because Husky's pre-commit hook runs the entire gate locally before a commit is even allowed to land: lint-staged, then pnpm lint, then pnpm test --passWithNoTests, then a full pnpm build. Every commit pays for a production build. Main can't be broken by construction, because nothing broken can get committed in the first place.

Commit messages are enforced too, and not just for shape - a custom commitlint rule rejects any commit whose scope isn't a real Jira ticket key, with a single escape hatch (na-0) for genuinely ticket-less infra work. There's a matching Commitizen adapter, cz-vinyl, that prompts interactively for the right format. Forty-two of the seventy commits in this repo's history carry the same ticket - KVP-759 - which makes the whole migration's timeline readable straight out of git log without needing a separate changelog anywhere.

No CI YAML anywhere in the repo - the pre-commit hook is the CI. A commit without a real Jira ticket in its scope never lands.

That rigor is also why a dead end shows up cleanly in the history instead of getting silently squashed away. A widget called SATSA got scaffolded alongside FEDHASA early on, then deleted one commit later - component, demo route, SVGs, font, all of it - with the commit message just saying it was no longer required. Scope changed mid-sprint. The history says so plainly instead of pretending the first attempt never happened.

What I'd do differently

  • Extract WidgetShell earlier. It didn't get factored out of the per-widget components until enough families existed to make the duplication impossible to ignore - waiting cost real repetition in the meantime.
  • Build the /test harness in week one, not week three. Pixel parity against the legacy widget should have been the first thing checked, not the thing that started mattering once families were already shipping.
  • Write down extraFtlNotInProductDropdown - the handful of legacy templates deliberately left out of scope - on day one instead of discovering the boundary by grepping for what wasn't in the new map yet.

The numbers

WhatBeforeAfter
Widget keys60+ .ftl files, one per variant60 keys, 21 families
RenderingJava/Spring + FreeMarkerNext.js 16 App Router, 39 components
Widget-only codePer-widget JS bundle each~8,500 LOC shared across families
Resize handlingPer-widget bespoke JS1 computeContainScale + ResizeObserver
Preview without backendNot possible/demo/:widget + full /test console
CINone dedicatedHusky pre-commit: lint, test, build, every commit
Adding a widget to an existing familyNew .ftl file, deployOne line in WIDGET_FAMILY_BY_KEY

Stack: Next.js 16, React 19 with the React Compiler, TypeScript 5, Tailwind CSS v4, TanStack Query, Zod, Node 24.

Tech used in this article

  • TypeScript
  • Next.js
  • React
  • Node.js