Als je een Matomo heatmap hebt geopend en kapotte icoontjes zag waar je logo en hero horen te staan, ontbrekende achtergrondafbeeldingen op elke sectie, en in de ergste gevallen een layout die eruitziet alsof de pagina vergeten is dat er CSS bestaat — de klikken zelf zijn prima. Matomo heeft ze op de juiste coördinaten opgeslagen. Wat kapot is, is de screenshot eronder. Alles op de pagina met een relatief pad heeft de reis naar Matomo's renderer niet overleefd, en je houdt een heatmap over die zweeft boven een wireframe van wat je bezoekers eigenlijk zagen.
Het patroon is consistent. Alles met een volledig gekwalificeerde URL zoals https://jouwsite.nl/images/hero.jpg rendert prima. Alles met een relatief pad (/images/hero.jpg, ./logo.svg, assets/bg.png) is kapot. Hetzelfde geldt voor stylesheets, lettertypen, scripts en CSS url(...)-verwijzingen in inline styles. Zodra je dat patroon herkent, is de oorzaak duidelijk.
De snelste oplossing is een gratis Chrome-extensie die wij onderhouden, Matomo Heatmap Helper. Die doorloopt de DOM en converteert elke relatieve URL naar absoluut vlak voor elke capture, en zet daarna de originelen terug. De rest van dit artikel laat zien hoe je hetzelfde doet zonder extensie, plus een paar permanente oplossingen die de moeite waard zijn om te implementeren.
Hoe je het oplost zonder de extensie
Er is een console-snippet dat het probleem oplost vlak voor elke capture, en een handvol permanente fixes die je met de site of de build kunt meesturen. Kies wat past bij de beperkingen waar je mee werkt.
Alle relatieve URL's op de pagina in kaart brengen
Voordat je iets aanpast, zoek je uit wat er eigenlijk relatief is. Open de pagina in Chrome, druk op F12 om DevTools te openen, ga naar de Console, en plak dit. Het dekt <img src>, <img srcset>, <source srcset>, <source src>, <video poster> en inline-style background-image:
// Plak in de browserconsole. Geeft alle relatieve resource-URL's weer.
(() => {
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;
})();Dit geeft je de exacte lijst van verwijzingen die de Matomo-renderer verkeerd gaat oplossen. Als de tabel leeg terugkomt maar de heatmap toch kapot is, zit het probleem ergens anders (CORS op een CDN, lettertypelicenties, scrollcontainers). Anders is elke rij in die tabel iets wat je moet herschrijven of naar een absolute URL moet verplaatsen.
De snelle fix: relatieve URL's omzetten naar absoluut vlak voor de capture
Dit is de snippet die we in de browserconsole plakken vlak voordat we Matomo's heatmap-capture triggeren. Het spiegelt de relative-url-fixer van de extensie: het doorloopt de DOM, lost elke relatieve resource-URL op ten opzichte van de huidige locatie van de pagina, en schrijft de absolute versie terug in het attribuut. Tegen de tijd dat Matomo de DOM serialiseert, is elke verwijzing volledig gekwalificeerd en haalt de renderer assets op van jouw origin in plaats van die van Matomo. <a href> laat het bewust ongemoeid, want nav-links herschrijven zou SPA-routing breken zonder dat het de screenshot beïnvloedt.
// Plak in de browserconsole. Converteert elke relatieve resource-URL naar
// absoluut zodat Matomo's server-side render ze oplost ten opzichte van jouw origin
// in plaats van die van Matomo. Slaat <a href> bewust over, want nav-links
// aanpassen zou SPA's breken en heeft geen invloed op de 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]. Herschrijf elke kandidaat en behoud de 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() in 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(`${count} relatieve URL's omgezet naar absoluut. Trigger nu je Matomo heatmap-capture.`);
})();Zodra de logregel verschijnt, capture je direct. De herschrijving leeft alleen tot de volgende navigatie. Let op: deze snippet ziet alleen inline style="..."-attributen, niet url(...)-aanroepen in externe stylesheets. Gebruik daarvoor de postbuild-rewriter verderop, of vertrouw op de build-time fix.
Een <base>-tag toevoegen in <head>
De eenvoudigst mogelijke permanente fix. Eén regel bovenaan <head>, en elke relatieve URL op de pagina wordt opgelost ten opzichte van jouw domein in plaats van welke host de HTML ook rendert:
<!-- Vervang yoursite.com door jouw domein -->
<base href="https://yoursite.com/">Het addertje onder het gras is dat <base> alle relatieve URL's sitebreed beïnvloedt, inclusief linkdoelen. SPA's die leunen op relatieve routing (React Router, Next.js <Link> in bepaalde configuraties, alles met client-side pushState tegen relatieve paden) kunnen zich gaan misdragen zodra je dit toevoegt. Test de navigatiestroom voordat je het live zet, of beperk de tag zodat die alleen wordt gerenderd tijdens heatmap-captures (via een queryparameter die je tracking-script aan- of uitzet, bijvoorbeeld).
Als je een <base>-tag kunt implementeren zonder SPA-navigatie te breken, is dit de oplossing en hoef je niet verder te lezen.
Absolute URL's genereren via je build
Als <base> geen optie is, stel je build zo in dat die assets in de eerste plaats met absolute URL's uitvoert. De meeste generators hebben daarvoor een instelling. Next.js heeft assetPrefix in next.config.js. Hugo heeft baseURL in config.toml. Astro heeft site in astro.config.mjs. Gatsby gebruikt pathPrefix samen met --prefix-paths tijdens de build. Zet de waarde op je productiedomein, bouw opnieuw, en elk <img>-, <link>- en <script>-tag die je generator uitvoert komt volledig gekwalificeerd terug. De Matomo-renderer haalt op van jouw origin en de heatmap werkt.
Het voorbehoud hier is dat absolute URL's bij build-time doorgaans alleen de assets dekken die het framework kent. Handgeschreven CSS (of CSS afkomstig van een CMS, of geschreven in een aangepast thema) glipt er vaak doorheen, en ook url(...)-verwijzingen in gecompileerde stylesheets. Als je de build correct hebt geconfigureerd maar de heatmap nog steeds ontbrekende achtergronden of icoontlettertypen heeft, is dat de kloof die je raakt.
Postbuild-rewriter voor CSS url()
Als je generator url(...) in gebouwde stylesheets mist, is dit een klein Node-script dat door de uitvoermap loopt en elk root-relatief pad herschrijft naar een absoluut pad:
// Vervang SITE door jouw domein. Uitvoeren met: 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);
}Koppel dit aan je CI als post-build-stap. Het is bewust beperkt tot root-relatieve paden (paden die beginnen met /), omdat het herschrijven van ./foo of ../bar vereist dat je weet waar de stylesheet in de uitvoerstructuur staat. Als je er veel van hebt, is de build-time fix hierboven het betere antwoord.
Waarom Matomo relatieve URL's niet kan oplossen
Matomo's screenshot-capture verloopt in twee stappen. Eerst serialiseert de tracker je live DOM naar HTML en stuurt die door. Vervolgens rendert je Matomo-server die HTML opnieuw om de screenshot te produceren die je in de heatmapweergave ziet. De HTML bevat nog steeds /images/hero.jpg precies zoals je het schreef. Maar de renderer draait niet op jouw domein, dus wanneer de browserengine die afbeelding probeert te laden, lost het relatieve pad op ten opzichte van Matomo's host. /images/hero.jpg wordt https://jouw-matomo-host/images/hero.jpg, en dat bestaat niet. Hetzelfde voor stylesheets, lettertypen en de url(...)-aanroepen in inline styles. De renderer vraagt Matomo's server om bestanden die Matomo's server niet kent, krijgt 404's terug, en je eindigt met een screenshot waarbij alles met een relatief pad ontbreekt.
Dit is ook waarom volledig gekwalificeerde URL's prima werken. Zodra een URL zijn eigen scheme en host heeft, weet de renderer precies waar die hem moet ophalen, en gaat het verzoek naar jouw origin zoals het hoort.
Wat er precies stukgaat
Een paar patronen die we steeds tegenkomen:
<img src="/images/hero.jpg">en varianten. Het meest zichtbare symptoom: kapotte icoontjes waar je hero, logo of productfoto's horen te staan.<link rel="stylesheet" href="/css/main.css">met een relatievehref. De hele stylesheet geeft een 404, waardoor de pagina rendert met browserstandaarden. Dit is het geval dat mensen beschrijven als "de heatmap ziet er volledig onstyled uit."- Inline
style="background-image: url(/images/bg.png)". Achtergronden verdwijnen, de layout ziet er verder goed uit maar is visueel kapot. srcset-kandidaten met relatieve paden in<img srcset>of<source srcset>. Welke kandidaat de Matomo-renderer kiest hangt af van zijn viewport-aanname, waardoor soms het ene pad wel wordt opgelost en het andere niet, en de breuk er intermitterend uitziet.@font-face src: url("/fonts/inter.woff2")gedeclareerd in een inline<style>-blok of een stylesheet die wel overleeft. Het lettertype geeft een 404 en valt terug op systeemstandaarden. (Dit overlapt met de post over lettertypen die niet laden, maar hier is de oorzaak padoplossing, niet CORS.)- SVG
<use href="/icons/sprite.svg#chevron">. De sprite laadt nooit, elk icoontje verdwijnt, en de layout klapt in rondom de lege<svg>-placeholders. <link rel="preload" as="font" href="...">met een relatievehref. Breekt de screenshot op zichzelf niet, maar het is een nuttig signaal dat andere assets in dezelfde familie waarschijnlijk ook kapot zijn.
Als je heatmap kapotte afbeeldingsplaceholders toont, ontbrekende CSS, of een layout die niet overeenkomt met je live site, heb je met minimaal één hiervan te maken. Vaak meer dan één op dezelfde pagina.
Dit hoort ook bij dezelfde familie van problemen die lettertypen, afbeeldingen, scrollcontainers en sticky headers in dezelfde screenshot breekt. We hebben een uitgebreidere post over kapotte Matomo heatmap-screenshots die de rest doorloopt, plus aparte posts over waarom lettertypen niet laden en waarom afbeeldingen niet laden, want die hebben elk hun eigen oorzaken.
Wat we zelf zouden doen
Als je een <base>-tag kunt implementeren zonder SPA-navigatie te breken, is dat de oplossing. Eén regel, elke relatieve URL wordt correct opgelost, klaar.
Als <base> conflicteert met je routing, stel je build zo in dat die absolute URL's voor assets uitvoert, en voeg de postbuild-CSS-rewriter toe voor url(...)-verwijzingen waar je build niet bij komt. Iets meer werk, grondiger, en het vecht niet met je framework.
Als geen van beide haalbaar is (CMS-beheerde templates die je niet kunt aanraken, thema's die je niet bezit, door derden geïnjecteerde markup), is de Chrome-extensie Matomo Heatmap Helper wat wij gebruiken op klantsites waar we de stack niet kunnen wijzigen. Die voert dezelfde herschrijflogica uit als de snippet hierboven, plus equivalente fixes voor lettertypen, afbeeldingen, scrollcontainers en sticky headers, bij elke capture. Gratis, open source, code op GitHub.
Martez is het grotere project waar de extensie uit is voortgekomen. Het verbindt Matomo met Meta Ads en Google Ads zodat ROAS, CLV en attributie naast je webanalytics staan in plaats van in een apart spreadsheet. Het zit in private beta. Meld je aan voor de wachtlijst als dat relevant voor je is.
De meeste problemen met relatieve URL's zijn één build-configuratie verwijderd. De moeite waard om één keer goed te regelen.