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, and sometimes 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 into Matomo's stored snapshot, 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, and the CSS url(...) references inside inline styles. Once you spot that pattern, the cause is usually a base-URL problem, which is the rest of this post.

TL;DR

Matomo rebuilds your captured page inside its own viewer, so a relative path like /images/hero.jpg resolves against the Matomo host and 404s. Make those resource URLs absolute and they'll resolve against your origin again: add a <base href>, have your build emit absolute asset URLs, or run the console rewrite below before capturing. The catch is that this only takes effect on a fresh snapshot, so delete the old screenshot to force Matomo to recapture.

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 the relative URLs 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>, <link href>, SVG <use>/<image>, and inline-style background-image:

js
// Paste into the browser console. Lists the common relative resource URLs.
(() => {
  const isRel = u => u && !/^([a-z]+:|\/\/|data:|blob:|#)/i.test(u.trim());
  const out = [];
  const push = (type, u, el) => isRel(u) && out.push([type, u, el]);
 
  document.querySelectorAll('img[src]').forEach(el => 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 => push('srcset', u, el)));
  document.querySelectorAll('video[poster]').forEach(el => push('video.poster', el.getAttribute('poster'), el));
  document.querySelectorAll('source[src]').forEach(el => push('source.src', el.getAttribute('src'), el));
  document.querySelectorAll('link[href]').forEach(el => push('link.href', el.getAttribute('href'), el));
  document.querySelectorAll('use, image').forEach(el =>
    push('svg use/image', el.getAttribute('href') || el.getAttribute('xlink:href'), el));
  document.querySelectorAll('[style*="url("]').forEach(el => {
    (el.getAttribute('style').match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g) || []).forEach(raw =>
      push('inline bg', raw.match(/url\(\s*['"]?([^'")]+)/)[1], el));
  });
 
  console.table(out.map(([t, u]) => ({ type: t, url: u })));
  return out;
})();

This catches the visible offenders. If it returns rows, fix those first. If it returns nothing and the screenshot is still broken, the relative reference is somewhere this snippet can't reach: url(...) inside a <style> block or an external stylesheet, a CORS-blocked CDN, or Matomo's own saved-CSS behavior. Those need a different fix than the one below.

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

This is the snippet we paste into the browser console right before triggering a new Matomo capture. It mirrors the extension's relative-url-fixer: it walks the DOM, resolves the common relative resource URLs against the page's current location, and writes the absolute version back into the attribute. By the time Matomo serializes the DOM, those references are fully-qualified, so when the heatmap viewer later fetches them it goes to your origin instead of the Matomo host. It deliberately leaves <a href> alone, since rewriting nav links would break SPA routing without affecting the screenshot.

js
// Paste into the browser console before triggering a Matomo capture. Converts
// the common relative resource URLs to absolute so Matomo's heatmap viewer
// resolves them against your origin instead of the Matomo host. 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], video[poster], stylesheets, icons, preloads
  document.querySelectorAll('img[src], source[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. SVG sprite + image references: <use>, <image> (and legacy xlink:href)
  document.querySelectorAll('use, image').forEach(el => {
    ['href', 'xlink:href'].forEach(attr => {
      const v = el.getAttribute(attr);
      if (isRel(v)) { el.setAttribute(attr, abs(v)); count++; }
    });
  });
 
  // 3. 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++; }
  });
 
  // 4. 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 capture the page.`);
})();

One thing it can't reach: url(...) calls inside external stylesheets or <style> blocks. It only sees inline style="..." attributes. For CSS-referenced backgrounds and fonts, use the postbuild rewriter further down or the build-time fix.

Force a fresh Matomo snapshot

The console rewrite only touches the live DOM. Matomo still has to capture a new snapshot for it to matter, and it won't overwrite a screenshot it already has. To recapture an existing broken heatmap:

  • Check that your IP isn't in Matomo's global or per-site exclusion list, or the capture is silently dropped.
  • Delete the existing screenshot for that heatmap in Matomo. It regenerates one using whatever CSS it can reach.
  • To control the timing for the console fix, turn on Capture Heatmap Snapshot Manually on the heatmap (Heatmap & Session Recording 5.1.0+). That clears the old snapshot and lets you fire the capture yourself: run the rewrite snippet, then _paq.push(['HeatmapSessionRecording::captureInitialDom', {idHeatmap}]) in the console once the page has fully loaded.
  • Append ?pk_hsr_forcesample=1 to the page URL so your visit lands in the sample.

Add a <base> tag in <head>

The simplest permanent fix. One line near the top of <head>, and every document-level relative URL resolves against your domain instead of whatever host happens to be displaying the HTML:

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

It's a blunt instrument, though. <base> rewrites the base for the whole document, link targets included, so it's worth a quick check before you ship it.

If you can ship a <base> tag without breaking navigation, this is the answer and you can stop reading. If it fights your routing, scope it so it only renders during heatmap captures (a query parameter your tracking script flips on, for example), or move on to the build-time fix.

Emit absolute URLs from your build

If <base> is off the table, the next option is to make your build emit absolute URLs for assets in the first place. Most generators have a knob for this, but the knobs do narrower things than you'd hope. None of them reliably absolutize every resource, so check the generated HTML rather than trusting the setting:

GeneratorSettingWhat it makes absoluteWhat it misses
Next.jsassetPrefix/_next/static/* JS and CSS chunkspublic/ files, /_next/data, anything you hardcode
HugobaseURL + absURL/absLangURL in templatesURLs built through those helpersraw relative paths in partials, hand-written CSS
Astrosite (+ base)generated metadata like canonical and sitemap URLs; base prefixes asset pathsruntime relative paths, imported asset URLs
GatsbypathPrefix + --prefix-pathsa subpath prefix on internal linksit prefixes a path, it doesn't add a host/domain

The common thread: build-time absolute URLs cover the assets the framework emits and little else. CSS you wrote by hand (or that comes from a CMS, or a custom theme) usually slips through, and so do the url(...) references inside compiled stylesheets. If you've configured the build and the heatmap still has missing backgrounds or icon fonts, that's the gap.

Postbuild rewrite for CSS url()

If your generator misses url(...) inside built stylesheets, this small Node script 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 CI as a post-build step. The (?!\/) in the regex is deliberate: it scopes the rewrite to root-relative paths (/...) and skips protocol-relative ones (//cdn.example.com/...), which already carry a host. Paths like ./foo or ../bar are left alone too, since rewriting those needs to know where the stylesheet sits 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 heatmap doesn't take a server-side screenshot of your page. When the tracker runs, it serializes your live DOM (the HTML structure as it stands after load) and sends that to Matomo, which stores it. Matomo also tries to read and cache your CSS so the layout still reproduces if the stylesheet later changes. Images and most other resources aren't stored; they're fetched live when you open the heatmap.

The catch is where "live" happens. When you view the heatmap, Matomo rebuilds that stored DOM inside its own viewer, on the Matomo host. A path like /images/hero.jpg has no host of its own, so the browser resolves it against the document it's sitting in, the Matomo viewer, and asks https://your-matomo-host/images/hero.jpg for a file that lives on your site. Matomo's host has never heard of it, returns a 404, and the image is gone. Same for fonts and the url(...) calls inside inline styles.

  1. 1

    Visitor loads your page

  2. 2

    Tracker serializes the live DOM

  3. 3

    Matomo stores the HTML snapshot + cached CSS

  4. 4

    You open the heatmap in Matomo's viewer

How is the resource URL written?

relative: /images/hero.jpg

Resolved against the Matomo host: 404

absolute: https://yoursite.com/…

Resolved against your origin: loads

Relative paths have no host of their own, so the heatmap viewer resolves them against Matomo instead of your site.

This is also why fully-qualified URLs work fine. Once a URL carries its own scheme and host, the browser fetches it from your origin no matter which document it's resolving against.

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.
  • Inline style="background-image: url(/images/bg.png)". Backgrounds disappear; the layout holds but reads as visually broken.
  • <link rel="stylesheet" href="/css/main.css"> with a relative href. Matomo often caches the CSS content in its database, so the rules usually survive even when the URL doesn't, but the relative url(...) references inside that CSS (backgrounds, @font-face sources) still resolve against the Matomo host and 404. If saved-CSS is disabled, you lose the whole stylesheet and the page renders with browser defaults: the "heatmap looks completely unstyled" case.
  • srcset candidates with relative paths inside <img srcset> or <source srcset>. Which candidate the viewer 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. 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.

Scripts are the exception. Matomo stores a static snapshot and doesn't re-run your site's JavaScript when it renders the heatmap, so a broken relative script src doesn't visually break the screenshot. It only matters if that script was building DOM before Matomo captured the snapshot.

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 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 navigation, that's the answer. One line, every document-level relative URL resolves correctly, done.

If <base> conflicts with your routing, configure your build to emit absolute URLs for assets, then add the postbuild CSS rewriter for the url(...) references your build doesn't reach. 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.

The durable fix is to make the HTML Matomo captures self-contained enough to replay from its own viewer: absolute resource URLs, and a recapture step you can repeat. Worth doing once.