Review sources

Reviews widget not updating on dynamic pages: AJAX, SPAs, infinite scroll

22 October 2026 · JustReview

A reviews widget stops updating on a dynamic page because its script runs once, when the document loads, and never looks at content that JavaScript inserts afterwards. Infinite scroll, AJAX filters, quick-view modals and client-side routing all change the page without loading a new document, so a container that arrives later simply stays empty. The fix is to call the widget’s init function again once the new container exists, and to call it exactly once per container, because a second call on a filled container stacks a duplicate. On 22 October 2026 we ran our own live embed script through each of these situations in a headless browser and counted widgets, slides, bytes and API requests.

Why an embed script misses content added later

We started with our own source rather than with theory. Every init function in justreview.js does three things in a fixed order: it requests review data from our API, it looks up its container with a single document.querySelector, and it writes markup into that container. After that the library is done. It does not register a MutationObserver, it does not listen for route changes, and it never looks at the page again.

For an ordinary page that loads once, this is the right design: one call, one request, no background work. On a dynamic page it produces two failure modes that are mirror images of each other. A container that appears after the call stays empty, which looks like an outage. A call that lands on a container that is already filled adds a second copy, because the builders write with innerHTML +=, appending rather than replacing. One of these is a missing widget and the other is a doubled one, and the cause is the same in both cases: the call and the container are out of step.

Our setup was a plain HTML page on localhost that loaded the production justreview.js and justreview.css from our CDN, driven by Chromium through Playwright at a 1280 px viewport. The widget was the testimonials carousel of our demo account with 20 reviews. A healthy mount gave one carousel, 29 slide elements including the loop clones, 361,938 bytes of HTML inside the container and one API request. Everything below is compared against that baseline.

What we measured on 22 October 2026

SituationCarouselsSlide elementsHTML in containerAPI requestsConsole errors
Static page, one init call129361,938 bytes10
SPA: leave the route and come back, no new call000 bytes0 new0
SPA: same, init called after the route change129361,938 bytes1 new0
AJAX hook calls init three times on one container3871,085,814 bytes30

The column that matters most is the last one. Not one of these situations logs an error, which is why they are so hard to diagnose from a support ticket. The vanished widget after a route change leaves a clean, empty <div> and a quiet network tab, so it looks exactly like our service being down. The tripled widget looks like a theme bug: the section grew to 2,591 px tall and carried more than a megabyte of duplicated markup, and none of that raised a warning.

The SPA rows also show the good news. Calling the init function after the new route had rendered was enough to restore the identical carousel, byte for byte, at the cost of one extra request. The whole problem is when to call it and how to avoid calling it twice.

Re-initialise once per container, never once per event

The duplicate is not caused by calling the function often. It is caused by calling it on a node that already holds a widget. So the guard belongs on the node, not on a timer:

<script>
  function mountReviews() {
    var el = document.getElementById('justreview-testimonials');
    if (!el || el.dataset.jrMounted) return;   // no container yet, or already filled
    el.dataset.jrMounted = '1';
    JustReview.initTestimonials('YOUR_USER_ID', { /* your config from the panel */ });
  }
</script>

Storing the flag as a data attribute on the container, rather than in a global variable, is what makes this work on dynamic pages. When a route change or an AJAX swap throws the old container away, its flag goes with it, and the fresh container is unmarked and gets its one call. We tested exactly that: three round trips away from the route and back, with the guard in place, produced one carousel every time and three API requests in total, one per return. Without the guard and with a global “already initialised” flag instead, the second visit would stay empty, because the flag outlives the node it was describing.

Use the call from your panel’s generated snippet as the body of this function. The library exposes its functions only on the JustReview object: in our test window.initTestimonials was undefined and JustReview.initTestimonials was a function, so call it the way the generated snippet does. The full list of functions and options is in our widget JavaScript reference.

MutationObserver: watch the grid, debounce, then guard

When your theme gives you no event to hook into, a MutationObserver is the generic fallback. It is also the easiest way to make things worse. We simulated an infinite-scroll category page: 24 product cards arriving in four batches of six, one card at a time, with the reviews carousel sitting outside the grid. Each variant below observed the grid and called the init function from its callback.

Observer setupInit callsCarouselsAPI requestsHTML in container
Grid, childList, no debounce2525259,048,450 bytes
Grid, 150 ms debounce5551,809,690 bytes
Grid, 150 ms debounce plus the mounted guard111361,938 bytes
document.body with subtree, no guard (capped at 30)30 in 2.2 ms303010,858,140 bytes

Debouncing alone helps less than people expect. It cut 24 observer callbacks down to four extra calls, one per batch, and every one of those calls still appended a full carousel, so the page ended with five. A debounce limits how often you call. Only the guard limits what a call can do.

The last row is the one to avoid entirely. Observing the whole body with subtree: true means the observer sees the widget’s own writes. The init call inserts a loading spinner, the spinner is a DOM change, the observer fires and calls init again, which inserts another spinner. Our test reached its safety cap of 30 calls in 2.2 milliseconds. In our first attempt we had set the cap at 400, and the tab stopped responding altogether until we killed the browser several minutes later. Without a cap, that is what a visitor gets.

The pattern that survived is short. Observe the narrowest element that actually changes, wait for the batch to settle, and let the guard decide whether anything needs mounting:

<script>
  var grid = document.querySelector('#product-grid');   // the element your theme refills
  var timer = null;
  if (grid) {
    new MutationObserver(function () {
      clearTimeout(timer);
      timer = setTimeout(mountReviews, 150);
    }).observe(grid, { childList: true });
  }
  mountReviews();
</script>

Our help centre has the short version of this fix next to the other reasons a widget can stop showing, such as a changed domain whitelist or an optimisation plugin deferring the script.

Use the hook your stack already fires

An observer guesses when the page has changed. Most stacks tell you outright, and those signals are cheaper and more precise. Put mountReviews() in whichever of these your site already uses:

  • jQuery-based themes and plugins: $(document).on('ajaxComplete', mountReviews) runs after every jQuery AJAX request, which covers most filter and load-more plugins built on it.
  • Hotwire Turbo: document.addEventListener('turbo:load', mountReviews) fires on the first load and after every visit.
  • Vue Router: router.afterEach(() => nextTick(mountReviews)), because the new view is only in the DOM after the next tick.
  • Infinite Scroll by Metafizzy: infScroll.on('append', mountReviews) after each new page of items.
  • swup 4: swup.hooks.on('page:view', mountReviews) once the new content is visible.
  • Your own fetch code: call it on the line after you insert the new HTML.

React and Next.js are a special case, because StrictMode runs effects twice on purpose and the server pass has no window at all. We measured those failures separately in our React and Next.js widget test, and the ref-based guard there is the React equivalent of the data attribute above.

Inline stars do not belong in every product card

A tempting idea on shop themes is to put our inline stars into each card of a category grid and re-initialise them as the grid grows. It does not work, and the reason is what the stars show. The stars widget displays your overall rating across the sources you connected, not a rating per product, and it renders into one element with the id justreview-stars.

We tested it anyway. In a grid of 12 cards that each carried that container, the stars appeared in the first card and in none of the other 11. After 12 more cards arrived and we called initStars again, the first card held two rows of stars and the other 23 cards held none. Even if it had filled every card, it would have told shoppers that each product carries your store-wide score, which is not what the number means. Place the stars once, near your logo or your main heading, and leave them out of any re-initialisation logic.

Widgets inside hidden modals and tabs

A quick-view modal or a tab panel that starts hidden is dynamic in a quieter way: the container exists at load, but it has no width. The carousel measures its container when it starts, so a widget initialised while hidden lays itself out for a width of zero. When we opened the modal, each review card was 1,160 px wide and the carousel showed one review per view instead of three.

Two fixes worked. Calling the init function when the modal opens, through the same guard, produced the normal layout of three cards at 387 px each. Leaving the early init in place and running window.dispatchEvent(new Event('resize')) right after the modal became visible also restored the three 387 px cards, because the slider recalculates on resize.

If the page already has a carousel and the modal needs its own, use the third argument of the init function. The second container keeps the same id plus an extra class, and the function gets that class with a leading dot, for example JustReview.initTestimonials('YOUR_USER_ID', config, '.qv') for <div id="justreview-testimonials" class="qv">. We placed a plain container and a qv container on one page, called the function with '.qv', and only the second one was filled.

What each re-initialisation costs

Every init call is a full round trip to our API plus a rebuild of the markup. Across ten clean runs on our test machine, the time from the call to the first slide in the DOM had a median of 70.5 ms, with a range of 67 to 91 ms. Once per route change, that is invisible to a visitor. Twenty-five times during one scroll it becomes 25 requests and nine megabytes of duplicated DOM, which is the difference between the guarded and unguarded rows in the second table. If you are already watching how scripts weigh on your pages, our page speed measurements cover the cost of the first load.

Why our widget on a dynamic site

The reason to pick us for a dynamic site is that you can see and control everything above. The widget renders into your own <div>, not into an iframe, so you can count its carousels, bytes and requests in the same test you already run, and fix any of the cases in this post with ten lines of your own code. One script tag, one config object, no framework package to keep in step with your React or Vue version, and the same three installation steps on a single-page app as on plain HTML, described in our guide to embedding Google reviews on any website.

The limits are worth stating plainly. The library has no built-in observer, so on a dynamic page the re-initialisation is your code, not ours. The append behaviour that causes the duplicate is ours to change, and until we do, the guard is not optional. And inline stars are a single, page-level element by design.

Every plan, including the free one, is on the pricing page. To try the guard and the observer above against a real account, connect your sources and paste your own ID into the snippet, create a free JustReview account and have your reviews on the page in a few minutes.

FAQ

Why does my reviews widget disappear after an AJAX update or a route change?

Because the script ran once, at page load, and the container it filled has since been replaced. An embed script does not watch the page. On 22 October 2026 we left a route in a test single-page app and came back: the new container held 0 widgets and 0 slides, the console showed 0 errors and our API received 0 new requests. One init call after the route change restored the full carousel at the cost of one extra request.

Is it safe to call the widget init function again?

Only on an empty container. Our init functions append their markup instead of replacing it, so a second call on a container that already holds the widget adds a second copy. Three calls on the same container produced 3 carousels, 87 slide elements, 1.09 MB of HTML and a section 2,591 px tall, with no warning in the console. Mark the container when you mount it and skip it the next time.

Should I use a MutationObserver to reinitialise a reviews widget?

Only on a narrow container, with a debounce and an already-mounted guard. Observing document.body with subtree turns into a loop, because the widget's own writes trigger the observer: our test hit its safety cap of 30 init calls in 2.2 ms. Watching only the product grid with a 150 ms debounce and a guard turned 24 inserted product cards into exactly one init call and one API request.

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.