Als je een Matomo heatmap hebt geopend en je hero ziet in Times New Roman, je knoppen in Arial, en de klikmarkeringen een paar pixels naast de elementen waar ze bij horen zweven — dan zijn de klikken zelf gewoon goed. Matomo heeft ze op de juiste coördinaten vastgelegd. Wat kapot is, is de screenshot eronder. Je merklettertypen zijn niet geladen toen de Matomo-server de pagina opnieuw renderde, dus werd er teruggevallen op welke systeemlettertypen er beschikbaar waren. Een ander lettertype betekent andere metriek, en andere metriek betekent dat de layout verspringt.
De snelste oplossing is een gratis Chrome-extensie die wij onderhouden, genaamd Matomo Heatmap Helper. Die embedt elk lettertype op de pagina als base64 vlak vóór elke heatmap-capture, zonder dat je stylesheets hoeft aan te passen. De rest van dit artikel legt uit hoe je hetzelfde doet zonder extensie, en (verderop) waarom Matomo je lettertypen überhaupt niet kan laden.
Hoe je het oplost zonder de extensie
Er is een snel console-snippet dat het probleem recht voor elke capture goedmaakt, en een paar permanente oplossingen die je in de site kunt verwerken. Kies wat bij je stack past. Er is ook een JS-patroon aan het einde dat de screenshot breekt, ongeacht welke oplossing je kiest — handig om van te weten.
Diagnose: wat gaat er eigenlijk mis?
Voordat je iets verandert, zoek je uit welke lettertypen niet laden. Open de pagina in Chrome, druk op F12 om DevTools te openen, ga naar de Console en plak dit:
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
[...document.fonts].forEach(f =>
console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});Alles met een andere status dan loaded is verdacht. Ga dan naar DevTools → Network → filter op "Font" en let op 403- of CORS-fouten bij je @font-face-URL's. Zo zie je of je te maken hebt met een CDN-probleem, een Adobe Fonts-probleem of een serialisatieprobleem.
De snelle fix: elk lettertype als base64 inbedden vanuit de console
Dit is het snippet dat we in de browserconsole plakken vlak voordat we Matomo's heatmap-capture triggeren. Het loopt langs elke @font-face-regel op de pagina (inline, same-origin en cross-origin stylesheets), haalt de lettertypebinaries op, codeert ze als base64 en injecteert een nieuwe <style>-tag met op zichzelf staande data-URI's. Tegen de tijd dat Matomo de DOM serialiseert, zijn de lettertypen er al in gebakken.
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
const fontFaces = [];
const parseSrc = (srcValue, baseUrl) => {
const out = [];
const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
let m;
while ((m = re.exec(srcValue)) !== null) {
const raw = m[1], format = m[2];
if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
}
return out;
};
const collect = (rule, baseUrl) => {
if (rule instanceof CSSFontFaceRule) {
const s = rule.style;
const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
const src = s.getPropertyValue('src');
if (!family || !src) return;
fontFaces.push({
family,
weight: s.getPropertyValue('font-weight') || '',
style: s.getPropertyValue('font-style') || '',
display: s.getPropertyValue('font-display') || '',
unicodeRange: s.getPropertyValue('unicode-range') || '',
src: parseSrc(src, baseUrl),
});
} else if (rule.cssRules) {
for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
}
};
// 1. Walk accessible stylesheets via the CSSOM
const crossOriginUrls = [];
for (const sheet of Array.from(document.styleSheets)) {
const baseUrl = sheet.href || location.href;
try {
for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
} catch {
if (sheet.href) crossOriginUrls.push(sheet.href);
}
}
// 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
const parseCssText = (txt, baseUrl) => {
const re = /@font-face\s*\{/gi;
let m;
while ((m = re.exec(txt)) !== null) {
const start = m.index + m[0].length;
let depth = 1, i = start;
while (depth > 0 && i < txt.length) {
if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
i++;
}
if (depth !== 0) continue;
const body = txt.substring(start, i - 1);
const get = n => {
const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
return r ? r[1].trim() : '';
};
const family = get('font-family').replace(/['"]/g, '').trim();
const src = get('src');
if (!family || !src) continue;
fontFaces.push({
family,
weight: get('font-weight'),
style: get('font-style'),
display: get('font-display'),
unicodeRange: get('unicode-range'),
src: parseSrc(src, baseUrl),
});
}
// Recurse into @import
const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
const imports = [];
while ((m = importRe.exec(txt)) !== null) {
const u = m[1] || m[2];
try { imports.push(new URL(u, baseUrl).href); } catch {}
}
return imports;
};
const visited = new Set();
const fetchCss = async (url) => {
if (visited.has(url)) return;
visited.add(url);
try {
const txt = await fetch(url).then(r => r.text());
const imports = parseCssText(txt, url);
for (const u of imports) await fetchCss(u);
} catch (e) { console.warn('CSS fetch failed:', url, e.message); }
};
for (const url of crossOriginUrls) await fetchCss(url);
if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
console.log(`Found ${fontFaces.length} @font-face rules.`);
// 3. Fetch each unique font binary and base64-encode it
const uniqueUrls = [...new Set(
fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
)];
console.log(`Fetching ${uniqueUrls.length} font files...`);
const dataUris = new Map();
await Promise.all(uniqueUrls.map(async (url) => {
try {
const blob = await fetch(url, { mode: 'cors' }).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.blob();
});
const dataUri = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(blob);
});
dataUris.set(url, dataUri);
} catch (e) { console.warn('Font fetch failed:', url, e.message); }
}));
// 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
const css = fontFaces.map(f => {
const srcParts = f.src.map(s => {
const u = dataUris.get(s.url) || s.url;
return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
});
if (srcParts.length === 0) return '';
const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
if (f.weight) props.push(`font-weight: ${f.weight}`);
if (f.style) props.push(`font-style: ${f.style}`);
if (f.display) props.push(`font-display: ${f.display}`);
if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
return `@font-face {\n ${props.join(';\n ')};\n}`;
}).filter(Boolean).join('\n\n');
// 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
const style = document.createElement('style');
style.setAttribute('data-heatmap-font-fix', 'true');
style.textContent = css;
document.head.appendChild(style);
console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
console.log('Now trigger your Matomo heatmap capture.');
})();Om te bevestigen dat de injectie daadwerkelijk is gelukt voordat je de screenshot maakt:
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
+ `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
: 'No injected style tag found, re-run the embed snippet.');Dit is de snelle weg. Het werkt voor eenmalige captures en reviewcycli waarbij je nu een schone screenshot nodig hebt en niet wilt wachten op een codewijziging. Voor alles wat je regelmatig opnieuw vastlegt, kun je beter een van de permanente oplossingen hieronder inzetten.
Lettertypen zelf hosten (de canonieke fix)
Voor bijna iedereen de schoonste aanpak. Download de woff2- en woff-varianten die je daadwerkelijk gebruikt via google-webfonts-helper, zet ze in /public/fonts en lever CSS als dit mee:
/* Edit the family name and file paths to match your downloaded files */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-400.woff2") format("woff2"),
url("/fonts/inter-400.woff") format("woff");
}Nu komen de lettertypen van je eigen domein. Zelfde origin als de pagina, geen CORS om over te onderhandelen, de Matomo-server haalt ze zonder problemen op en je screenshots renderen in het juiste lettertype. Het is ook sneller voor je echte gebruikers, omdat je een DNS-lookup en een third-party-verbinding van elke paginalading hebt weggehaald. Meestal is het een paar uur werk en een kleine CSS-diff.
CORS op je CDN instellen
Als je de lettertypen liever op een CDN laat staan, stel dan het CDN in om Access-Control-Allow-Origin: * terug te geven op lettertyperesponses en voeg crossorigin toe aan de preload-link:
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
href="https://your-cdn.example.com/inter.woff2"
crossorigin="anonymous">Dit werkt voor Cloudflare, Fastly, AWS en alles wat je zelf beheert. Het werkt niet voor Adobe Fonts. De domein-allowlist daar wordt server-side afgedwongen en je kunt hem niet aanpassen. Als je op Typekit zit, is zelf hosten de enige weg. Adobe's licentiebeleid rondom zelf hosten is een verhaal apart.
De embedding in je build verwerken
Als je de stylesheet niet beheert maar de build wel, voer dan dezelfde logica uit als het console-snippet, maar op build-tijd. Start een headless browser op (Playwright of Puppeteer), voer de embed-code uit tegen je gebouwde pagina, leg het resulterende <style>-blok vast en schrijf dat in je HTML-template. De pagina wordt geleverd met lettertypen die er al in zijn ingebakken. Geen runtime-fetch, geen consoleplak vóór elke capture.
Dit is de zwaarste van de drie permanente oplossingen. Het dekt ook het geval waarbij de marketingsite op een CMS staat dat je de stylesheet niet laat aanraken, maar wél een build-script toestaat.
Het serialisatieprobleem
Nog één ding dat mensen verrast. Lettertypen die via JS worden geladen op runtime overleven Matomo's DOM-serialisatie niet:
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);Het lettertype is geregistreerd in de document.fonts-set van de browser, maar het staat niet in de HTML die de serializer leest. De screenshot valt terug op systeemstandaarden, ook als het lettertype technisch gezien "loaded" is in je browser. Hetzelfde geldt voor lettertypen die door JS-frameworks worden geïnjecteerd op hydratietijd. Gebruik een <style>-tag met @font-face-regels, of een gewone <link rel="stylesheet">. Alles wat de serializer kan zien in statische HTML is prima. document.fonts.add() is voor hem onzichtbaar.
Waarom Matomo je lettertypen niet kan laden
Matomo's screenshot-capture verloopt in twee stappen. Eerst serialiseert de tracker je live-DOM naar HTML en stuurt die op. Dan rendert je Matomo-server die HTML tot de screenshot die je in de heatmapweergave ziet. Geen browsersessie, geen cookies, geen domein. Alleen een HTML-bestand dat koud wordt gerenderd vanaf een ander IP-adres.
Alles in die HTML dat naar buiten wijst naar een third-party host moet eerst langs die third party. Voor @font-face-declaraties stopt het daar.
Wat er precies fout gaat
Een paar patronen die we steeds tegenkomen:
- Het CDN dat je lettertypen serveert, geeft geen
Access-Control-Allow-Origin-headers terug. De browser tolereert het same-origin-geval zonder die headers. Matomo's server, die ophaalt vanaf een andere origin, doet dat niet. - Adobe Fonts (Typekit) valideert de
Origin- enReferer-headers tegen de domeinen die je in je account hebt toegestaan. Matomo's server staat nooit op die lijst, en Typekit antwoordt met 403. Er is ook geen manier om Matomo's IP toe te voegen. fonts.gstatic.comen een paar andere CDN's beperken server-side renderers of blokkeren ze volledig, soms op user-agent, soms op IP-reputatie. De lettertypen werken in je browser en niet in Matomo's renderer.- Lettertypen geladen met
new FontFace(...).load()en daarnadocument.fonts.add(...)staan helemaal niet in de geserialiseerde HTML. De serializer leest de DOM, niet de runtime-FontFace-set.
Als je heatmap-screenshot het verkeerde lettertype toont, heb je te maken met minimaal één van deze gevallen. Vaak meer dan één.
Dit is ook dezelfde familie van problemen die afbeeldingen, scrollcontainers en sticky headers in dezelfde screenshot breekt. We hebben een uitgebreider artikel over kapotte Matomo heatmap-screenshots dat de rest doorloopt.
Wat we zelf zouden doen
Als je de stylesheet beheert: zelf hosten. Het lost de heatmap op, verwijdert een third-party afhankelijkheid en maakt de pagina ook sneller voor je echte gebruikers.
Als dat niet kan: embed de lettertypen als base64 data-URI's in je bestaande stylesheet. Zelfde effect op Matomo's renderer, geen infrastructuurwijzigingen.
Als geen van beide haalbaar is, is de Matomo Heatmap Helper Chrome-extensie wat we gebruiken op clientsites waar we de stack niet mogen aanraken. Die voert dezelfde embed-logica uit als het snippet hierboven, plus equivalente fixes voor afbeeldingen, scrollcontainers en sticky headers, bij elke capture. Gratis, open source, code op GitHub.
Martez is het grotere project waar de extensie uit voortkomt. Het koppelt Matomo aan Meta Ads en Google Ads, zodat ROAS, CLV en attributie naast je webanalytics staan in plaats van in een aparte spreadsheet. Het is in private beta. Schrijf je in voor de waitlist als dat relevant voor je is.
Lettertypen zijn meestal oplosbaar in een stylesheet. De moeite waard om één keer goed te doen.