Why relative URLs aren't loading in your Matomo heatmap (and how to fix it)

Matomo heatmap screenshots break when your page uses relative paths. The renderer resolves them against Matomo's host instead of yours, so images, stylesheets, and fonts 404. Here's why it happens and how we deal with it.

If you've opened a Matomo heatmap and found broken-image icons where your logo and hero should be, missing background images on every section, and (in the worst cases) a layout that looks like the page forgot its CSS exists, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. Anything on the page with a relative path didn't survive the trip to Matomo's renderer, so you're left with a heatmap floating over a wireframe of what your visitors actually saw.

The tell is consistent. Anything with a fully-qualified URL like https://yoursite.com/images/hero.jpg renders fine. Anything with a relative path (/images/hero.jpg, ./logo.svg, assets/bg.png) is broken. Same goes for stylesheets, fonts, scripts, and CSS url(...) references inside inline styles. Once you spot that pattern, the cause is obvious.

The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It walks the DOM and converts every relative URL to absolute right before each capture, then restores the originals afterwards. The rest of this post is how to do the same thing without an extension, plus a few permanent fixes worth shipping.

How to fix it without the extension

There's a console snippet that papers over the problem right before each capture, and a handful of permanent fixes you can ship with the site or the build. Pick whichever fits the constraints you're working under.

List every relative URL on the page

Before you change anything, find out what's actually relative. Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste this. It covers <img src>, <img srcset>, <source srcset>, <source src>, <video poster>, and inline-style background-image:

js
// Paste into the browser console. Lists every relative resource URL.
(() => {
  const isRel = u => u && !/^([a-z]+:|\/\/|data:|blob:|#)/i.test(u.trim());
  const out = [];
  document.querySelectorAll('img[src]').forEach(el =>
    isRel(el.getAttribute('src')) && out.push(['img.src', el.getAttribute('src'), el]));
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el =>
    el.getAttribute('srcset').split(',').map(s => s.trim().split(/\s+/)[0])
      .forEach(u => isRel(u) && out.push(['srcset', u, el])));
  document.querySelectorAll('video[poster]').forEach(el =>
    isRel(el.getAttribute('poster')) && out.push(['video.poster', el.getAttribute('poster'), el]));
  document.querySelectorAll('source[src]').forEach(el =>
    isRel(el.getAttribute('src')) && out.push(['source.src', el.getAttribute('src'), el]));
  document.querySelectorAll('[style*="url("]').forEach(el => {
    const m = el.getAttribute('style').match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g) || [];
    m.forEach(raw => {
      const u = raw.match(/url\(\s*['"]?([^'")]+)/)[1];
      if (isRel(u)) out.push(['inline bg', u, el]);
    });
  });
  console.table(out.map(([t, u]) => ({ type: t, url: u })));
  return out;
})();

That gives you the exact list of references the Matomo renderer is going to misresolve. If the table comes back empty and the heatmap is still broken, the problem is somewhere else (CORS on a CDN, font licensing, scroll containers). Otherwise, every row in that table is something you need to either rewrite or move to an absolute URL.

The quick fix: rewrite relative URLs to absolute right before capture

This is the snippet we paste into the browser console right before triggering Matomo's heatmap capture. It mirrors the extension's relative-url-fixer: it walks the DOM, resolves every relative resource URL against the page's current location, and writes the absolute version back into the attribute. By the time Matomo serializes the DOM, every reference is fully-qualified and the renderer fetches assets from your origin instead of Matomo's. It deliberately leaves <a href> alone, since rewriting nav links would break SPA routing without affecting the screenshot.

js
// Paste into the browser console. Converts every relative resource URL to
// absolute so Matomo's server-side render resolves them against your origin
// instead of Matomo's. Skips <a href> deliberately, since changing nav links
// would break SPAs and doesn't affect the screenshot.
(() => {
  const base = location.href;
  const isRel = u => u && !/^([a-z]+:|\/\/|data:|blob:|#)/i.test(u.trim());
  const abs = u => { try { return new URL(u, base).href; } catch { return u; } };
  let count = 0;
 
  // 1. img[src], source[src], script[src], video[poster], stylesheets, icons, preloads
  document.querySelectorAll('img[src], source[src], script[src]').forEach(el => {
    const v = el.getAttribute('src');
    if (isRel(v)) { el.setAttribute('src', abs(v)); count++; }
  });
  document.querySelectorAll('video[poster]').forEach(el => {
    const v = el.getAttribute('poster');
    if (isRel(v)) { el.setAttribute('poster', abs(v)); count++; }
  });
  document.querySelectorAll('link[rel="stylesheet"], link[rel~="icon"], link[rel="preload"]').forEach(el => {
    const v = el.getAttribute('href');
    if (isRel(v)) { el.setAttribute('href', abs(v)); count++; }
  });
 
  // 2. img[srcset], source[srcset]. Rewrite each candidate and preserve descriptors.
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el => {
    const srcset = el.getAttribute('srcset');
    const rewritten = srcset.split(',').map(part => {
      const trimmed = part.trim();
      const [u, ...rest] = trimmed.split(/\s+/);
      return isRel(u) ? [abs(u), ...rest].join(' ') : trimmed;
    }).join(', ');
    if (rewritten !== srcset) { el.setAttribute('srcset', rewritten); count++; }
  });
 
  // 3. CSS url() inside inline styles
  document.querySelectorAll('[style*="url("]').forEach(el => {
    const before = el.getAttribute('style');
    const after = before.replace(
      /url\(\s*['"]?([^'")]+)['"]?\s*\)/g,
      (whole, u) => isRel(u) ? `url("${abs(u)}")` : whole
    );
    if (after !== before) { el.setAttribute('style', after); count++; }
  });
 
  console.log(`Rewrote ${count} relative URLs to absolute. Now trigger your Matomo heatmap capture.`);
})();

When the log line shows up, capture immediately. The rewrite only lives until the next navigation. Note that this snippet only sees inline style="..." attributes, not url(...) calls inside external stylesheets. For those, see the postbuild rewriter further down or rely on the build-time fix.

Add a <base> tag in <head>

The simplest possible permanent fix. One line at the top of <head>, and every relative URL on the page resolves against your domain instead of whatever host happens to be rendering the HTML:

html
<!-- Edit yoursite.com to your domain -->
<base href="https://yoursite.com/">

The catch is that <base> affects every relative URL site-wide, including link targets. SPAs that lean on relative routing (React Router, Next.js <Link> in some configurations, anything with client-side pushState against relative paths) can misbehave once you add it. Test the navigation flow before you ship, or scope the tag so it only renders during heatmap captures (a query parameter your tracking script flips on, for example).

If you can ship a <base> tag without breaking SPA navigation, this is the answer and you can stop reading.

Emit absolute URLs from your build

If <base> is off the table, configure your build to emit absolute URLs for assets in the first place. Most generators have a knob for it. Next.js has assetPrefix in next.config.js. Hugo has baseURL in config.toml. Astro has site in astro.config.mjs. Gatsby uses pathPrefix together with --prefix-paths at build time. Set the value to your production domain, rebuild, and every <img>, <link>, and <script> tag your generator emits comes out fully-qualified. The Matomo renderer fetches from your origin and the heatmap works.

The gotcha here is that build-time absolute URLs usually only cover the assets the framework knows about. CSS authored by hand (or coming from a CMS, or written into a custom theme) often slips through, and so do url(...) references inside compiled stylesheets. If you've configured the build correctly and the heatmap still has missing backgrounds or icon fonts, that's the gap you're hitting.

Postbuild rewrite for CSS url()

If your generator misses url(...) inside built stylesheets, this is a small Node script that walks the output directory and rewrites every root-relative path to an absolute one:

js
// Edit SITE to your domain. Run with: node rewrite.js
import { readFileSync, writeFileSync } from 'fs';
import { globSync } from 'glob';
const SITE = 'https://yoursite.com';
for (const f of globSync('out/**/*.{html,css}')) {
  const out = readFileSync(f, 'utf8').replace(
    /url\(\s*['"]?(\/[^'")]+)['"]?\s*\)/g,
    (_, p) => `url(${SITE}${p})`
  );
  writeFileSync(f, out);
}

Wire it into your CI as a post-build step. It's deliberately scoped to root-relative paths only (paths starting with /), since rewriting ./foo or ../bar requires knowing where the stylesheet lives in the output tree. If you have a lot of those, the build-time fix above is the better answer.

Why Matomo can't resolve relative URLs

Matomo's screenshot capture is a two-step process. First, the tracker serializes your live DOM into HTML and ships it off. Then, your Matomo server re-renders that HTML to produce the screenshot you see in the heatmap view. The HTML still contains /images/hero.jpg exactly as you wrote it. But the renderer isn't running on your domain, so when the browser engine goes to load that image, it resolves the relative path against Matomo's host. /images/hero.jpg becomes https://your-matomo-host/images/hero.jpg, which doesn't exist. Same for stylesheets, fonts, and the url(...) calls inside inline styles. The renderer asks Matomo's server for files Matomo's server has never heard of, gets back 404s, and you get a screenshot with everything relative-pathed missing.

This is also why fully-qualified URLs work fine. Once a URL has its own scheme and host, the renderer knows exactly where to fetch it, and the request goes to your origin like it should.

What's actually failing

A few patterns we keep running into:

  • <img src="/images/hero.jpg"> and friends. The most visible symptom: broken-image icons where your hero, logo, or product photos should be.
  • <link rel="stylesheet" href="/css/main.css"> with a relative href. The entire stylesheet 404s, so the page renders with browser defaults. This is the case people describe as "the heatmap looks completely unstyled."
  • Inline style="background-image: url(/images/bg.png)". Backgrounds disappear, the layout looks fine but reads as visually broken.
  • srcset candidates with relative paths inside <img srcset> or <source srcset>. Which candidate the Matomo renderer picks depends on its viewport assumption, so sometimes one path resolves and another doesn't, and the breakage looks intermittent.
  • @font-face src: url("/fonts/inter.woff2") declared inside an inline <style> block or a stylesheet that does survive. The font 404s and you fall back to system defaults. (This overlaps with the fonts not loading post, but here the cause is path resolution, not CORS.)
  • SVG <use href="/icons/sprite.svg#chevron">. The sprite never loads, every icon goes missing, and the layout collapses around the empty <svg> placeholders.
  • <link rel="preload" as="font" href="..."> with a relative href. Doesn't break the screenshot on its own, but it's a useful tell that other assets in the same family are probably broken too.

If your heatmap is showing broken-image placeholders, missing CSS, or a layout that doesn't match your live site, you're hitting at least one of these. Often more than one on the same page.

This is also the same family of problem that breaks fonts, images, scroll containers, and sticky headers in the same screenshot. We've written a longer post on broken Matomo heatmap screenshots that walks through the rest, plus separate posts on why fonts aren't loading and why images aren't loading since those each have their own causes.

What we'd actually do

If you can ship a <base> tag without breaking SPA navigation, that's the answer. One line, every relative URL resolves correctly, done.

If <base> conflicts with your routing, configure your build to emit absolute URLs for assets, and add the postbuild CSS rewriter for url(...) references your build doesn't reach. Slightly more work, more thorough, doesn't fight your framework.

If neither is reachable (CMS-managed templates you can't touch, themes you don't own, third-party-injected markup), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. It runs the same rewrite logic as the snippet above, plus equivalent fixes for fonts, images, scroll containers, and sticky headers, on every capture. Free, open source, code on GitHub.

Martez is the larger project the extension came out of. It connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. Join the waitlist if that's relevant to you.

Most relative URL problems are a build config away. Worth doing once.