Platforms

React reviews widget: the four ways it breaks in Next.js

24 August 2026 · JustReview

An embed script and a React tree both want to own the same piece of the DOM, and neither of them knows the other exists. That single conflict explains nearly every problem people hit when they drop a reviews widget into a React or Next.js app: the widget that renders twice, the widget that renders nothing at all with an empty console, and the build that dies with window is not defined. On 24 August 2026 we instrumented our own script inside React 18.3.1 and a production next build of Next.js 14.2.15 and counted what came out in each case. Below are those counts, the component that survived all of them, and the one line in our own source that causes the duplicate.

The line that causes the duplicate render

Our widget builders write into your container with +=, not =. In the current source, five of the six write element.innerHTML += markup, and only the floating badge does a clean widget.innerHTML = markup. Append is a perfectly reasonable choice for a page that loads once and calls the init function once. It becomes a problem the moment something calls that function twice against the same node, which is exactly what React 18 does in a dev build.

StrictMode deliberately mounts, unmounts and remounts every component so that effects with missing cleanup announce themselves. For a well-behaved React component that is harmless. For an outside script that appends, it doubles the page. Same container, two copies of everything:

Measured in the containerSingle mountReact 18 StrictMode
Widget roots rendered12
Slides in the DOM2754
Author blocks2448
Bytes of HTML inside the container335,617671,234
Rendered height at 1280 px wide753 px1,517 px
Requests to our reviews API12
Warnings in the console00

Nothing in that right-hand column raises an error. You get a section twice as tall, roughly 655 KB of duplicated DOM, two network round trips, and a slider whose arrows now drive the wrong copy, because the navigation selector is global rather than scoped to your container. The one signal that something is wrong is visual, and it only appears in a dev build, which is precisely where people assume duplicates are just StrictMode being noisy and move on.

The cleanup that makes it worse, not better

The reflex fix is to clear the container in the effect’s cleanup function. We tried it and measured it, and it does not work:

Component under StrictModeWidget rootsSlidesBytes in containerAPI calls
Naive: init in useEffect, no cleanup254671,2342
With el.innerHTML = '' in the cleanup254671,2202
With a useRef init guard127335,6171

The reason is timing. initTestimonials is asynchronous: it fires a request, waits, and only then writes markup. Your cleanup runs synchronously the instant React unmounts, which is long before that request comes back. So the cleanup empties a container that is still empty, the in-flight response lands afterwards, the second effect fires its own request, and you are back to two copies. Clearing the container is not wrong, it is simply too early to matter. The thing that has to be idempotent is the call, not the node.

The component that survived every case

A ref guard is enough, because a ref survives the StrictMode remount while a local variable does not. Add polling for the library, because with a deferred or afterInteractive script tag there is no guarantee window.JustReview exists at the moment your effect runs:

'use client';
import { useEffect, useRef } from 'react';
import Script from 'next/script';

const HASH = 'YOUR_ACCOUNT_HASH';
const CFG = { /* the full config block from the panel */ };

export default function Reviews() {
  const started = useRef(false);

  useEffect(() => {
    if (started.current) return;
    let stop = false;

    const tick = () => {
      if (stop) return;
      if (window.JustReview?.initTestimonials) {
        started.current = true;
        window.JustReview.initTestimonials(HASH, CFG);
        return;
      }
      setTimeout(tick, 120);
    };

    tick();
    return () => { stop = true; };
  }, []);

  return (
    <>
      <div id="justreview-testimonials" />
      <Script
        src="https://justreview.co/widget/justreview.js"
        strategy="afterInteractive"
      />
    </>
  );
}

Three details in there are load-bearing. started is a ref, so the guard is not reset between the two StrictMode passes. stop cancels the polling loop rather than the render, which is the only part we can actually cancel. And the container is a plain <div> that React creates and then never touches again, so React’s reconciler and our script are not fighting over the same children.

That component, running in a real next build && next start, produced one widget root, 27 slides, 753 px of height, one API call, zero page errors and zero hydration warnings. It is the same output as a static HTML page, which is the point.

Next.js: two failures, one loud and one silent

The loud one first. If any module in the server pass touches window, document or localStorage, Next.js does not just skip that page. Our deliberately broken test route made next build stop with ReferenceError: window is not defined followed by Export encountered errors on following paths. The whole build fails, so nothing ships. Annoying, but honest, and it goes away the moment the code lives behind 'use client' inside an effect.

The silent one is worse, and it is specific to next/script. beforeInteractive sounds like the strategy you want for a widget you would like to appear early. It is not, and it fails without a word:

On our Next.js 14.2.15 test pageafterInteractivebeforeInteractive
onLoad callback firedn/a0 times
onReady callback firedn/a0 times
window.JustReview definedyesyes
Container present in the DOMyesyes
Reviews rendered27 slides0
Calls to our API10
Errors or console outputnonenone

Every ingredient is on the page and the result is nothing. The library loaded, the container exists, and the callback that was supposed to connect them never ran, so our script was never asked to do anything. This is the same failure shape we found on live sites built with page builders, where the library and the container end up on different pages: the browser downloads everything and quietly throws it away. Our post on adding the widget to WordPress covers the CMS version of the same trap.

Route changes are cheaper than people fear, and more expensive than they should be

The other common worry is client-side navigation: leave the page, come back, get two widgets. We tested it directly by unmounting and remounting the naive component across eight different timings, from 100 ms to 1,200 ms after mount, deliberately straddling the moment the API response arrives.

All eight runs produced the same thing: one widget root, 27 slides, 24 author blocks, 753 px. Not one duplicated. The builder starts with const el = document.querySelector(...); if (!el) return;, so a response that lands while the container is gone is simply dropped.

What every run did produce was a second call to our API. Navigation does not break the widget, it re-buys the data. If a reviews section appears on several routes, mount it once above the router rather than inside each page, or accept one extra round trip per visit. On a marketing site that is noise. On an app where users bounce between routes, it adds up.

Two copies on one page: keep the id, add a class

Rendering the same widget twice with different settings is supported, and the mechanism is easy to get subtly wrong, because our script builds its selector by string concatenation: it looks for #justreview-testimonials plus whatever you pass as the third argument. That third argument therefore has to be a CSS selector that composes with the id, and it must start with a dot.

What you writeRendersHeightVerdict
<div id="justreview-testimonials">, no third argument27 slides753 pxCorrect default
id="justreview-testimonials" class="reviews-home" + '.reviews-home'27 slides753 pxCorrect for two copies
<div id="justreview-testimonials-x"> + '-x'27 slides1,577 pxRenders unstyled
<div class="reviews-home"> + 'reviews-home'0 slides0 pxSilent nothing

That third row is the one that catches React developers, because renaming an id to keep it unique is an instinct. Our production stylesheet is 109,861 bytes and 352 of its rules are anchored to the literal id #justreview-testimonials. Rename the container and the reviews still arrive, they just arrive with none of that styling, which is why the same 27 slides occupy 1,577 px instead of 753 px. Keep the id, vary the class, and pass the class with its dot. The full walkthrough is in showing the same widget twice on one page.

What the crawler sees, said plainly

There is one thing a client-side widget cannot do, and it is worth stating rather than glossing over. In our Next.js test, the server response was 4,052 bytes containing an empty container and zero review text. After hydration the DOM was 339,820 bytes. Every review lives in that gap. Crawlers that execute JavaScript cross it, and when we read our API logs on 13 August 2026 we saw Googlebot, bingbot, Applebot and Meta’s crawler all fetching widget data. Crawlers that do not execute JavaScript, including OpenAI’s and Perplexity’s, never requested it once.

So a React reviews widget is social proof for humans and for the crawlers that render, not a way to put review text into a static HTML file. If you need the text itself in the server response, it has to come from your server, which for a Next.js app is the easy case: our widget data endpoint is a plain public GET, so a server component can fetch it and render whatever markup you like.

Why our widget for a React project specifically

The reason to pick us here is that everything above is measurable in our script rather than hidden behind a rendered iframe. The container is your <div>, the markup lands in your DOM, and you can count slides, bytes and API calls in the same test suite you already run. An iframe-based widget sidesteps the StrictMode duplicate by making the whole thing opaque, and takes your ability to inspect, style or measure it with the same move. One script tag, one config object, no framework dependency, and no npm package that pins you to a React major version. Our installation guide for any website is the same three steps whether the site is React, plain HTML or WordPress, and every plan is on the pricing page. If you want to try the component above against a real account, create one free and paste your hash into it.

The caveats belong here rather than buried: the append behaviour described at the top is ours to fix and we have not fixed it yet, which is why the ref guard exists. There is no official npm package, so the component above is code you own rather than a dependency we version for you. And the SSR gap is real for the two crawlers named above. We would rather you know all three before you install than after.

FAQ

Why does my reviews widget appear twice in React?

Almost always React 18 StrictMode, which runs every effect twice on purpose in a dev build. Our builder appends its markup instead of replacing it, so the second run stacks a second copy on top of the first. We measured it on 24 August 2026: one mount produced 27 slides and 335,617 bytes inside the container, the StrictMode mount produced 54 slides and 671,234 bytes, and nothing was logged as a warning.

Where do I call the widget init function in Next.js?

Inside a useEffect in a component marked 'use client', never at module scope and never in a server component. Touching window during the server pass does not just blank the page, it fails the build: our test route made next build exit with ReferenceError: window is not defined and Export encountered errors on following paths.

Can I use next/script with strategy beforeInteractive for a reviews widget?

No, and it fails without saying anything. On our Next.js 14.2.15 test page with beforeInteractive, onLoad and onReady each fired exactly zero times, while window.JustReview was defined and the container was in the DOM. The result was zero reviews rendered and zero calls to our API, with an empty console. Use afterInteractive.

Does the widget re-fetch reviews when I navigate between routes?

Yes, once per mount. We ran eight unmount and remount timings between 100 ms and 1,200 ms and every one produced a single clean widget, 27 slides, plus a second round trip to our API. Client-side navigation does not duplicate the widget, it just pays for the data again, so hoist the widget above the router if a section is present on several routes.

Real reviews, live from three sources

This slider is not a screenshot. It pulls our own reviews from Facebook, Capterra and G2 through the same widget you would install.