maravillajanm

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.

Umbraco

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 a load listener with { once: true } in case another instance is still loading. Only then does it append a new async script. 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's data-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 = false before 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-expanded and aria-activedescendant. It supports arrow, Enter and Escape keys and closes on an outside pointerdown. CMS entries without a calculator ID are filtered out before rendering. I reserved a min-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 as role="img" with an aria-label.

  • Small details: Dates read as "8 days ago" using Intl.RelativeTimeFormat with numeric: "auto", with no date library. Previous/next buttons disable at either end. A fixed min-height on 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/script with strategy="afterInteractive". It renders nothing if its NEXT_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's onLoad has 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-articles that forwards only the query parameters it expects, using cache: "no-store". It returns 400 if the folder ID is missing and 502 if 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: and blob: URLs alone. A companion function rewrites src, href and every srcset candidate 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 with matchAll, 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 the lg: 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, avoiding dangerouslySetInnerHTML. 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-* and data-button-value attributes 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 with closest() to collect the section and component context and pushes an element_click event to the Google Tag Manager dataLayer in 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 dataLayer push 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.2

  • Integrations: 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 calculator

  • Browser APIs: Intl.RelativeTimeFormat, CSS cascade layers, WAI-ARIA listbox pattern