Warum relative URLs in deiner Matomo-Heatmap nicht laden (und wie du das behebst)

Matomo-Heatmap-Screenshots brechen zusammen, wenn deine Seite relative Pfade nutzt. Der Renderer löst sie gegen Matomos Host auf statt gegen deinen, also kommen Bilder, Stylesheets und Schriftarten als 404 zurück. Hier erfährst du, warum das passiert und wie wir damit umgehen.

Wenn du eine Matomo-Heatmap geöffnet hast und statt Logo und Hero nur kaputte Bildsymbole siehst, Hintergrundbilder in jedem Abschnitt fehlen und das Layout im schlimmsten Fall aussieht, als hätte die Seite ihr CSS vergessen – die Klicks selbst sind in Ordnung. Matomo hat sie an den richtigen Koordinaten aufgezeichnet. Was kaputt ist, ist der Screenshot darunter. Alles auf der Seite mit einem relativen Pfad hat die Reise zu Matomos Renderer nicht überlebt, also schwimmt deine Heatmap über einem Wireframe dessen, was deine Besucher tatsächlich gesehen haben.

Das Erkennungsmuster ist eindeutig. Alles mit einer vollständigen URL wie https://yoursite.com/images/hero.jpg rendert einwandfrei. Alles mit einem relativen Pfad (/images/hero.jpg, ./logo.svg, assets/bg.png) ist kaputt. Gleiches gilt für Stylesheets, Schriftarten, Skripte und CSS-url(...)-Referenzen in Inline-Styles. Sobald du dieses Muster erkennst, ist die Ursache offensichtlich.

Die schnellste Lösung ist eine kostenlose Chrome-Erweiterung, die wir betreuen: Matomo Heatmap Helper. Sie geht das DOM durch und wandelt jede relative URL direkt vor der Erfassung in eine absolute um, stellt danach die Originale wieder her. Der Rest dieses Beitrags zeigt, wie du dasselbe ohne Erweiterung machst – plus ein paar dauerhafte Fixes, die sich zu shippen lohnen.

Wie du es ohne die Erweiterung behebst

Es gibt ein Konsolenschnipsel, das das Problem direkt vor der Erfassung überklebt, sowie eine Handvoll dauerhafter Fixes, die du mit der Seite oder dem Build ausliefern kannst. Nimm, was zu deinen Rahmenbedingungen passt.

Alle relativen URLs auf der Seite auflisten

Bevor du etwas änderst, finde heraus, was eigentlich relativ ist. Öffne die Seite in Chrome, drück F12 für die DevTools, wechsel zur Console und füge das hier ein. Es deckt <img src>, <img srcset>, <source srcset>, <source src>, <video poster> und background-image in Inline-Styles ab:

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;
})();

Das gibt dir die genaue Liste der Referenzen, die der Matomo-Renderer falsch auflösen wird. Kommt die Tabelle leer zurück und die Heatmap ist trotzdem kaputt, liegt das Problem woanders (CORS auf einem CDN, Schriftartlizenzierung, Scroll-Container). Ansonsten ist jede Zeile in dieser Tabelle etwas, das du entweder umschreiben oder auf eine absolute URL umstellen musst.

Der schnelle Fix: Relative URLs direkt vor der Erfassung in absolute umwandeln

Das ist der Schnipsel, den wir in die Browserkonsole einfügen, kurz bevor wir Matomos Heatmap-Erfassung auslösen. Er spiegelt den relative-url-fixer der Erweiterung: Er geht das DOM durch, löst jede relative Ressourcen-URL gegen den aktuellen Seitenstandort auf und schreibt die absolute Version zurück ins Attribut. Wenn Matomo das DOM serialisiert, ist jede Referenz vollständig qualifiziert und der Renderer holt Assets von deinem Origin statt von Matomos. <a href> wird bewusst ausgelassen, weil das Umschreiben von Navigationslinks das SPA-Routing kaputt machen würde, ohne den Screenshot zu beeinflussen.

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.`);
})();

Sobald die Log-Zeile erscheint, sofort erfassen. Die Umschreibung gilt nur bis zur nächsten Navigation. Beachte: Dieser Schnipsel sieht nur style="..."-Inline-Attribute, keine url(...)-Aufrufe in externen Stylesheets. Dafür gibt es weiter unten den Postbuild-Rewriter – oder den Build-Zeit-Fix.

Ein <base>-Tag im <head> hinzufügen

Der denkbar einfachste dauerhafte Fix. Eine Zeile oben im <head>, und jede relative URL auf der Seite wird gegen deine Domain aufgelöst statt gegen den Host, der das HTML gerade rendert:

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

Der Haken: <base> wirkt auf jede relative URL der gesamten Seite, auch auf Link-Ziele. SPAs, die auf relativem Routing aufbauen (React Router, Next.js <Link> in manchen Konfigurationen, alles mit clientseitigem pushState gegen relative Pfade), können sich komisch verhalten, sobald du das einfügst. Teste den Navigationsfluss, bevor du das auslieferst – oder schränke das Tag so ein, dass es nur während Heatmap-Erfassungen gerendert wird (etwa über einen Query-Parameter, den dein Tracking-Skript aktiviert).

Wenn du ein <base>-Tag ausliefern kannst, ohne die SPA-Navigation zu brechen, ist das die Antwort – und du kannst aufhören zu lesen.

Absolute URLs aus dem Build ausgeben

Wenn <base> nicht in Frage kommt, konfiguriere deinen Build so, dass er Assets von vornherein mit absoluten URLs ausgibt. Die meisten Generatoren haben dafür einen Schalter. Next.js hat assetPrefix in next.config.js. Hugo hat baseURL in config.toml. Astro hat site in astro.config.mjs. Gatsby nutzt pathPrefix zusammen mit --prefix-paths zur Buildzeit. Den Wert auf deine Produktionsdomain setzen, neu bauen, und jedes <img>-, <link>- und <script>-Tag, das dein Generator ausgibt, kommt vollständig qualifiziert raus. Der Matomo-Renderer holt von deinem Origin, die Heatmap funktioniert.

Der Haken: Build-Zeit-absolute-URLs decken meist nur die Assets ab, die das Framework kennt. CSS, das von Hand geschrieben wurde (oder aus einem CMS kommt, oder in ein Custom-Theme eingebaut ist), rutscht oft durch – genauso url(...)-Referenzen in kompilierten Stylesheets. Wenn der Build korrekt konfiguriert ist und in der Heatmap trotzdem Hintergründe oder Icon-Fonts fehlen, genau da liegt die Lücke.

Postbuild-Rewrite für CSS-url()

Wenn dein Generator url(...)-Aufrufe in gebauten Stylesheets übersieht, ist das hier ein kleines Node-Skript, das das Ausgabeverzeichnis durchgeht und jeden root-relativen Pfad in einen absoluten umschreibt:

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);
}

Als Post-Build-Schritt in die CI einbinden. Das Skript beschränkt sich bewusst auf root-relative Pfade (Pfade, die mit / beginnen), weil das Umschreiben von ./foo oder ../bar erfordert zu wissen, wo das Stylesheet im Ausgabebaum liegt. Wer davon viele hat, ist mit dem Build-Zeit-Fix oben besser bedient.

Warum Matomo relative URLs nicht auflösen kann

Matomos Screenshot-Erfassung läuft in zwei Schritten ab. Zuerst serialisiert der Tracker dein Live-DOM in HTML und schickt es weg. Dann rendert dein Matomo-Server dieses HTML, um den Screenshot zu erzeugen, den du in der Heatmap-Ansicht siehst. Das HTML enthält nach wie vor /images/hero.jpg genauso, wie du es geschrieben hast. Der Renderer läuft aber nicht auf deiner Domain – wenn die Browser-Engine das Bild laden will, löst sie den relativen Pfad gegen Matomos Host auf. /images/hero.jpg wird zu https://your-matomo-host/images/hero.jpg, das dort nicht existiert. Gleiches gilt für Stylesheets, Schriftarten und url(...)-Aufrufe in Inline-Styles. Der Renderer fragt Matomos Server nach Dateien, von denen Matomos Server noch nie gehört hat, bekommt 404er zurück, und du siehst einen Screenshot, in dem alles mit relativen Pfaden fehlt.

Deshalb funktionieren vollständig qualifizierte URLs einwandfrei. Hat eine URL einmal ihr eigenes Schema und ihren eigenen Host, weiß der Renderer genau, wo er sie holen soll, und die Anfrage geht an deinen Origin – wie es sein soll.

Was genau fehlschlägt

Ein paar Muster, die uns immer wieder begegnen:

  • <img src="/images/hero.jpg"> und ähnliches. Das sichtbarste Symptom: kaputte Bildsymbole dort, wo Hero, Logo oder Produktfotos sein sollten.
  • <link rel="stylesheet" href="/css/main.css"> mit relativem href. Das gesamte Stylesheet kommt als 404 zurück, die Seite rendert mit Browser-Defaults. Das ist der Fall, den Leute als "die Heatmap sieht komplett unstyled aus" beschreiben.
  • Inline style="background-image: url(/images/bg.png)". Hintergründe verschwinden, das Layout sieht okay aus, wirkt aber visuell kaputt.
  • srcset-Kandidaten mit relativen Pfaden in <img srcset> oder <source srcset>. Welchen Kandidaten der Matomo-Renderer auswählt, hängt von seiner Viewport-Annahme ab – manchmal löst ein Pfad auf und ein anderer nicht, der Fehler sieht dann sporadisch aus.
  • @font-face src: url("/fonts/inter.woff2") in einem Inline-<style>-Block oder einem Stylesheet, das es schafft. Die Schriftart kommt als 404, du fällst auf Systemschriften zurück. (Das überschneidet sich mit dem Beitrag zu Schriftarten, die nicht laden, aber hier liegt die Ursache in der Pfadauflösung, nicht in CORS.)
  • SVG-<use href="/icons/sprite.svg#chevron">. Das Sprite lädt nie, jedes Icon fehlt, das Layout kollabiert um die leeren <svg>-Platzhalter herum.
  • <link rel="preload" as="font" href="..."> mit relativem href. Bricht den Screenshot an sich nicht, ist aber ein nützlicher Hinweis, dass andere Assets in derselben Familie wahrscheinlich ebenfalls kaputt sind.

Wenn deine Heatmap kaputte Bild-Platzhalter, fehlendes CSS oder ein Layout zeigt, das nicht zu deiner Live-Seite passt, trifft dich mindestens eines davon. Meistens mehrere gleichzeitig auf derselben Seite.

Das ist auch dieselbe Problemfamilie, die Schriftarten, Bilder, Scroll-Container und fixierte Header in demselben Screenshot kaputt macht. Wir haben einen längeren Beitrag zu fehlerhaften Matomo-Heatmap-Screenshots geschrieben, der den Rest durchgeht – plus separate Beiträge darüber, warum Schriftarten nicht laden und warum Bilder nicht laden, weil jedes seine eigenen Ursachen hat.

Was wir tatsächlich tun würden

Wenn du ein <base>-Tag ausliefern kannst, ohne die SPA-Navigation zu brechen, ist das die Antwort. Eine Zeile, jede relative URL löst korrekt auf, fertig.

Wenn <base> mit deinem Routing kollidiert, konfiguriere deinen Build so, dass er absolute URLs für Assets ausgibt, und füge den Postbuild-CSS-Rewriter für url(...)-Referenzen hinzu, die dein Build nicht erreicht. Etwas mehr Aufwand, gründlicher, kämpft nicht gegen dein Framework.

Wenn keines von beidem erreichbar ist (CMS-verwaltete Templates, die du nicht anfassen kannst, Themes, die dir nicht gehören, Markup von Drittanbietern), ist der Matomo Heatmap Helper das, was wir auf Kundensites einsetzen, bei denen wir den Stack nicht ändern können. Er führt dieselbe Umschreibe-Logik wie der Schnipsel oben aus, plus entsprechende Fixes für Schriftarten, Bilder, Scroll-Container und fixierte Header – bei jeder Erfassung. Kostenlos, Open Source, Code auf GitHub.

Martez ist das größere Projekt, aus dem die Erweiterung entstanden ist. Es verbindet Matomo mit Meta Ads und Google Ads, sodass ROAS, CLV und Attribution neben deiner Webanalyse liegen statt in einer separaten Tabelle. Es ist in der privaten Beta. Trag dich auf die Warteliste ein, wenn das für dich relevant ist.

Die meisten relativen-URL-Probleme sind eine Build-Konfiguration entfernt. Lohnt sich, das einmal zu erledigen.

Warum relative URLs in deiner Matomo-Heatmap nicht laden (und wie du das behebst) - Martez Blog