Frontend · Umbraco
Next.js Case Study: Reviews, Calculators and Click Tracking
How I built the customer-review carousel, calculator embed and click tracking for a national mortgage lender's headless Next.js 16 website rebuild.
Overview
A national mortgage lender rebuilt its marketing website on a headless setup: the content management system (CMS) holds the content and a separate Next.js front end displays it. I was a front-end developer in a multi-agency team. I built the features that connect the site to outside services and turn editors' content into working page sections.
Customer reviews: I built a rating bar and a review carousel that pull live scores and reviews from the company's review provider.
Mortgage calculators: I embedded a third-party calculator suite, with a menu to switch between calculators, and restyled it to match the new design.
Visitor tracking: I added the click tracking that tells marketing which page sections and buttons visitors use.
Page sections: I built icon lists, article listings, two-column layouts, and the rules that keep phone numbers, footnotes and images displaying correctly.
The problem
The new site had to match a fresh design and still work with outside services the business depends on. Those services weren't designed for a modern front end, and some of the content editors write needed extra handling.
Tools that fight the page: The calculator vendor's script builds its own markup and only expects to run once, so it doesn't fit naturally into a React page.
Outside services can fail: If the review provider goes down, the page around it still has to load.
Reports had to keep working: Marketing already tracked clicks by page section, so the new site needed the same labels.
Editor-friendly content: Editors type phone numbers, footnote markers and buttons straight into text fields. The site has to turn those into proper links, symbols and buttons without editors changing how they work.
The approach
The front end runs on Next.js 16.2 (App Router) with React 19.2 and TypeScript 6, styled with Tailwind CSS 4.2. It reads content on the server from Umbraco 13's Content Delivery API v2 and is hosted on Node.js 24 on Azure App Service. Other developers on the team built the rendering pipeline, caching and hosting. This section covers the code I wrote on top of it: third-party embeds, review data, script loading, a same-origin proxy, CMS content handling and click tracking.
1. Embedding a third-party calculator inside React
The calculator vendor ships a script that scans the page for placeholder elements and builds its own markup inside them. Editors can add several calculators to one section, and visitors switch between them from a menu.
Load the vendor script once: An effect first checks for the vendor's global object. Next it looks for an existing
<script src>, attaching aloadlistener with{ once: true }in case another instance is still loading. Only then does it append a newasyncscript. The script is never removed on unmount, so remounts and other calculator blocks reuse it.Let the vendor own its markup: React renders only an empty container held by a
ref. When a visitor picks a calculator, I clear the container, append a fresh element carrying that calculator'sdata-guid, and call the vendor's loader. React never reconciles nodes that the vendor script rearranges.Gotcha, the vendor only scans once: After its first run, the loader sets an internal "loaded" flag and ignores new placeholders, so switching calculators did nothing. I reset
isLoaded = falsebefore each call. That depends on an undocumented internal, so I typed it through a narrow interface to keep any future break in one place. The team later extracted this mounting logic into a shared hook that both calculator blocks now use.Accessible picker, stable layout: I built the picker as a custom listbox with
aria-haspopup="listbox",aria-expandedandaria-activedescendant. It supports arrow, Enter and Escape keys and closes on an outsidepointerdown. CMS entries without a calculator ID are filtered out before rendering. I reserved amin-height(750px) for the late-injected markup so the content below doesn't shift, and restyled the vendor's markup, including chart text, through an override stylesheet.
2. Server-rendered customer reviews
The rating bar and review carousel use data the CMS backend fetches from the review provider. They needed to render quickly, match the provider's look and fail quietly.
Typed responses: I wrote TypeScript interfaces mirroring the provider's v1 business-unit and review payloads, and added a reviews fetcher alongside the existing summary call, so components can't read fields that don't exist.
Server Components with graceful failure: The section is an async Server Component that loads the summary and the reviews in parallel with
Promise.all. If either fails, production renders nothing and development shows a visible placeholder, so the problem gets noticed without breaking the page. Only the carousel's previous/next state runs on the client, so review text arrives as HTML.Fractional star ratings: Each of the five stars is filled to a width of
min(max(score − i, 0), 1), so a 4.5 shows four and a half stars. The group is exposed to screen readers asrole="img"with anaria-label.Small details: Dates read as "8 days ago" using
Intl.RelativeTimeFormatwithnumeric: "auto", with no date library. Previous/next buttons disable at either end. A fixedmin-heighton the review card, tuned from 420px to 320px, stops reviews of different lengths from resizing the section.
3. Marketing scripts that don't block the page
The business also needed a personalisation beacon and a live-chat widget. Both are third-party scripts that shouldn't delay the first render or load in the wrong order.
Deferred and switchable: The personalisation beacon loads through
next/scriptwithstrategy="afterInteractive". It renders nothing if itsNEXT_PUBLIC_*variable is empty, so each environment can turn it off.Gotcha, build-time variables: Next.js writes
NEXT_PUBLIC_*values into the bundle at build time. They therefore have to be set in the build pipeline, not in the server's runtime settings, where they have no effect.Ordered loading for chat: The chat vendor's helper script depends on its core library. I render the second
<Script>only after the first one'sonLoadhas set React state, which removes the race. Switching from a re-implementation to the vendor's own helper cut the setup script from 453 lines to 82.Style isolation: The chat vendor's stylesheet is imported into a named CSS cascade layer (
@import url(…) layer(…)), so our unlayered Tailwind utilities win without!important. Later, the team moved chat loading into an embed field that editors manage in the CMS, so this loader is no longer in the release.
4. A same-origin proxy for blog pagination
The blog's "Load more" button called an absolute production API URL from the browser. Cross-origin (CORS) rules blocked it on the development and test sites, and every environment was reading production data.
Route Handler proxy: I added a Route Handler at
/api/blog-articlesthat forwards only the query parameters it expects, usingcache: "no-store". It returns400if the folder ID is missing and502if the upstream call fails.Media URLs resolved on the server: The handler turns relative image paths into absolute URLs before responding, so the browser bundle never needs the CMS hostname.
Right data per environment: The button now calls its own origin, so each environment reads from its own CMS.
5. Turning CMS content into components
Editors write rich text, links and short labels in the CMS. Several conventions in that content needed code to render correctly.
Media URLs: I wrote a resolver that prefixes relative media paths with the CMS origin and leaves absolute, protocol-relative,
data:andblob:URLs alone. A companion function rewritessrc,hrefand everysrcsetcandidate inside rich-text HTML, keeping width descriptors intact.Buttons inside rich text: Editors mark buttons either with a
button-*class or as a link on its own line. I extract these withmatchAll, render each as a real button component, remove any paragraphs left empty, and skip image links so linked charts don't turn into buttons.Phone links by device: A
tel:link stays a tappable button on phones and tablets. From thelg:breakpoint up, it shows the number as text formatted in 3-3-4 groups, and the number isn't repeated when the label already contains it.Footnotes and labels: I convert both
[SUPERSCRIPT: x]tokens and literal<sup>tags into real<sup>React elements, avoidingdangerouslySetInnerHTML. Topic labels convert to Title Case while keeping acronyms, so "FHA loans" doesn't become "Fha Loans".
6. Click-tracking taxonomy and listener
Marketing needed click data broken down by page section, using the same attribute names as the old site so that existing reports and funnels kept working.
Attributes on server-rendered markup: I stamped
data-section-*,data-component-*anddata-button-valueattributes across the blocks, header and heroes, and passed each section's position down as a prop through the row and block renderers. Rich-text sections take their name from their first heading, with footnote markers removed.One delegated listener: A client hook registers a single
document.addEventListener("click", handler, { capture: true }). It walks up the DOM withclosest()to collect the section and component context and pushes anelement_clickevent to the Google Tag ManagerdataLayerin snake_case. Untagged clicks are ignored. The listener landed four days after the attributes; until then the attributes did nothing.What I'd change: I typed the values by hand in each component, and they drifted into mixed casing within one pass. I'd also used visible headings, which editors change, as section names. A team review moved naming to values derived from the CMS. It also confirmed that the session-analytics tool's autocapture already reads these attributes, so my duplicate custom event was dropped and the
dataLayerpush kept.
The outcome
Most of these lessons came from fixing my own first attempts, which is why I trust them.
Let outside tools own their space: The calculator only worked reliably once I stopped making React manage its markup and gave it a container of its own. I now plan third-party embeds this way from the start.
Agree tracking names before tagging: Labels I typed by hand drifted within days. Now I agree the naming rules up front and generate the labels in code.
Check what the platform already does: I hand-built a chat loader that a simple CMS field later replaced. Now I ask what the CMS and tools already handle before writing new code.
Technologies
Platform: Umbraco 13 headless CMS (Content Delivery API v2), Node.js 24, Azure App Service
Front end: Next.js 16.2.11 (App Router, Server Components, Route Handlers,
next/script), React 19.2.5, TypeScript 6.0.3, Tailwind CSS 4.2.4 with @tailwindcss/typography 0.5.19, Font Awesome 6.7.2Integrations: Google Tag Manager (
dataLayer), FullStory Browser SDK 2.0.8 (later removed in favour of a CMS-managed embed), Salesforce Marketing Cloud Personalization, Cognigy Webchat, Trustpilot review data, an embedded third-party mortgage calculatorBrowser APIs:
Intl.RelativeTimeFormat, CSS cascade layers, WAI-ARIA listbox pattern