How to set up Matomo Heatmaps so the screenshot matches the page

Setup is two jobs, not one. Turning the plugin on takes ten minutes. Making the screenshot Matomo produces actually look like your live site is the longer half. Here's the path we walk through on every client setup.

TL;DR

Setting up Matomo heatmaps is two jobs, not one. The quick one takes about ten minutes: enable the Heatmap & Session Recording plugin, confirm matomo.js is shipping the tracker, create a heatmap with a matching target URL, and mask sensitive DOM before the first capture. The slower one is getting the screenshot Matomo reconstructs to actually match your live page, since late rendering, blocked CSS or fonts, a sticky header, or consent that never enables the tracker can each break it. Look at that first snapshot before you trust any click, move, or scroll data.

If this is the week you're finally adding heatmaps to your Matomo, the install itself is a 10-minute job. The hard part is what comes back. Half the time the fonts are wrong, the hero image is a broken icon, the sticky header is stamped down the page like a postage mark, or the product grid stops at the first row.

That's usually not a configuration mistake. Heatmaps and session recordings are client-side browser features. The tracker captures a sanitized page structure and interaction data in the visitor's browser, then sends that data to Matomo. When you view the heatmap later, Matomo rebuilds that stored structure, and the images and fonts have to load again from your site or CDN. Matomo stores some of your CSS itself, but the rest is fetched live. Anything that depended on late JavaScript, cookies, blocked assets, or viewport-specific layout can look wrong.

That split is why setup never feels like one job. Turning the plugin on, configuring a heatmap, and masking sensitive content is the short half. Making the reconstructed snapshot render your site honestly is the long one. This post covers the short half in detail and points at the per-issue posts when the longer one bites.

  1. 1

    Visitor browser

    tracker captures the page in the visitor's session

    breaks hereLate JS or a route that isn't ready yet
  2. sanitized DOM and interactions
    2

    Matomo storage

  3. 3

    Heatmap viewer reconstructs the page

    breaks hereSticky layout or nested scroll containers
  4. 4

    CSS, images, fonts load from your site or CDN

    breaks hereCORS, mixed content, missing assets
Where heatmap screenshots usually break

A note before we start: heatmaps and session recordings ship in the same plugin and use the same tracking code, but you configure them independently. Both work in browsers only, both need JavaScript on the visitor's side, and both need an accessible configs.php on your Matomo server. iOS, Android, and desktop apps don't get heatmaps.

Turn the plugin on

On Matomo Cloud, availability depends on your plan; on Cloud Business, Matomo documents the plugin as configured and ready to go. On a self-hosted instance, install Heatmap & Session Recording from the Marketplace UI or the CLI:

bash
./console plugin:install HeatmapSessionRecording
./console plugin:activate HeatmapSessionRecording

Then go to Administration → Diagnostic → System Check and confirm the Heatmap & Session Recording check is green.

If it's not green, the most common reason is that matomo.js isn't writable by the webserver or PHP user. The plugin injects its tracker into that file when possible, and if the file is read-only your site can keep shipping the old tracker code. Prefer ownership over broad write permissions, then rebuild:

bash
chown $phpuser matomo.js
./console custom-matomo-js:update
Use broad write permissions only as a temporary check

Matomo's docs show chmod a+w matomo.js as one way to make the file writable. Treat that as a troubleshooting shortcut, not the final state. Tighten ownership or permissions again according to your deployment model after custom-matomo-js:update has rebuilt the tracker.

If you have Cloudflare or another CDN in front of Matomo, purge the cache for matomo.js after that rebuild. Otherwise visitors keep getting the cached version that has no heatmap tracker in it, and you'll spend an hour wondering why nothing is recording.

Confirm the tracker is in place

You don't need to add anything to your tracking code. The standard Matomo JS snippet from Administration → Websites → Tracking Code already loads matomo.js, and the heatmap tracker is now part of that file.

Two cases where you do need to do something extra.

First, if matomo.js genuinely can't be made writable on your server (some hosted setups), include the heatmap tracker manually next to your normal Matomo tag:

html
<script src="https://your-matomo-host/plugins/HeatmapSessionRecording/tracker.min.js"></script>

Second, if your site is a single-page app or otherwise renders late, the default initial snapshot can happen when the browser considers the page loaded, before your route, lazy images, or client components are actually ready. The result is a perfectly empty screenshot with all the right click coordinates plotted on top of nothing. The fix is to disable the heatmap tracker early, then enable it once your app has rendered:

js
_paq.push(['HeatmapSessionRecording::disable']);
// ... after your app/route is fully rendered:
_paq.push(['HeatmapSessionRecording::enable']);

Interactions before the re-enable call are not recorded, so only use this when the early snapshot is actually broken.

Create your first heatmap

In Heatmaps → Manage Heatmaps → Create New Heatmap:

FieldRecommended first valueWhyWhen to change it
NameHome / May 2026You will need to find it later.Add campaign, locale, or template names when many pages share a layout.
Target page ruleequals simple for a single canonical pageIt ignores protocol, URL parameters, and trailing slashes, which is usually what you want. Use the validator before saving.Use equals exactly only when the whole URL must match. Use starts with, contains, or regex for groups of pages.
Screenshot URLThe clean canonical URLThis is the page Matomo uses for the visual snapshot when multiple URLs match the rule.Set it when the target rule catches UTM URLs, A/B variants, localized paths, or query-heavy URLs.
Sample rate100% on low-traffic pagesYou get data quickly while you are validating the setup.Drop to 10 to 25 percent on high-traffic pages to reduce tracking requests, stored events, and reporting volume. It does not mean Matomo re-renders a screenshot for every visit.
Sample limitA concrete number you can reviewKeeps a runaway heatmap from collecting forever.Raise it for seasonal pages or low-traffic pages that need more time to reach significance.
Breakpoint widthsUse Matomo's desktop, tablet, and mobile viewsMatomo can analyze heatmaps by device type and width, so one heatmap can still be read separately by viewport class.Create separate heatmaps only when you need different page rules, screenshot URLs, sampling, or campaign boundaries.
Excluded elementsCookie banners, popups, chat widgets, survey overlaysElement hiding is for visual clutter that would obscure the screenshot.Do not use this as privacy masking. Sensitive DOM should be masked in your HTML before capture.
Manual snapshotLeave off for a normal static pageMatomo automatically captures the initial snapshot on page load.Enable it on Heatmap & Session Recording 5.1.0+ when SPA/lazy content needs an explicit captureInitialDom after the page is ready.

Capture and scheduling limits are set globally under Administration → System → General Settings → Heatmap & Session Recording. If you're capturing across many sites, set a reasonable global cap so one runaway heatmap doesn't fill your storage.

From Matomo 5.4+, the heatmap list has a copy icon to duplicate an existing heatmap to another site and tweak. Useful when you've finally got one site dialled in and now have to do the same thing on the next nine.

Applies toHeatmap & Session Recording 5.1.0+ manual snapshotMatomo 5.4.0+ copy heatmapCloud or On-Premise

Mask sensitive content before the first capture

Heatmaps and session recordings minimize data in the browser before it is sent to Matomo, but you should still mask account-specific text before the first capture. Add data-matomo-mask to any element whose text shouldn't end up in screenshots or recordings:

html
<div data-matomo-mask>{{ user.email }}</div>

Password fields and credit-card inputs are always masked and you cannot opt back in, which is correct. Since Heatmap & Session Recording 3.2.0, keystroke recording is off by default and has to be enabled in the recording configuration. Leave it off unless you have a specific, consented need. If you want to enforce no keystroke capture even when someone enables it in the UI, do it once at tracker level:

js
_paq.push(['HeatmapSessionRecording::disableCaptureKeystrokes']);

data-matomo-unmask exists for the rare case where a parent element is masked but a specific child should stay readable. Use it sparingly. The default should be that text is masked unless you've explicitly checked it's safe to capture.

Take the first screenshot before you trust any data

This is the step most setup guides skip and it's the one that matters. Matomo automatically captures the initial heatmap snapshot on page load after a matching pageview. After you save a heatmap, visit the target page in a normal browser session, then open the heatmap report and actually look at the snapshot before you trust any data.

Does the snapshot match the live page?

Check the snapshot against the live page: same fonts, same images, same layout, one sticky header at the top, complete scroll containers, and separate desktop, tablet, and mobile views that each look right at their own width.

If the first snapshot was captured too early, you have three supported options:

SituationRecapture workflow
Static page, assets restoredDelete the existing heatmap screenshot in Matomo. Matomo will create a fresh one on a later matching pageview. You can force a sample by appending ?pk_hsr_forcesample=1 to the page URL.
SPA or lazy page on Heatmap & Session Recording 5.1.0+Edit the heatmap, enable "Capture Heatmap Snapshot Manually," copy Matomo's generated _paq.push(['HeatmapSessionRecording::captureInitialDom', {idHeatmap}]) command, then run it after the page has fully rendered.
Late-rendered page where you control the appDisable Heatmap & Session Recording early and enable it after the route/content is ready, using the snippet from the tracker section above.

If everything renders cleanly, you're done. Configure the rest of your heatmaps and move on. If something looks wrong, you've hit the longer half of setup. Read on.

Where it usually breaks

Six patterns we see on basically every site we set up. Most sites hit at least three.

  • Custom fonts fall back to Times or Arial because the font or CSS files are CORS-protected when Matomo tries to reconstruct the heatmap view. We've written a post on Matomo heatmap fonts not loading that walks through self-hosting and the CORS settings.
  • CDN-hosted images come back as broken icons because the heatmap viewer or snapshot cannot load them later, the file moved, the asset is private, or hot-link protection blocks the request. The images-not-loading post covers CORS, hot-link protection, and inlining LCP images at build time.
  • Relative URLs like /images/hero.jpg resolve against your Matomo host instead of your site, and 404. The relative-URLs post covers <base href> and rewriting paths in postbuild.
  • Sticky and fixed headers either repeat down the page or freeze across the middle. The sticky header post ships a console snippet and a permanent CSS pattern scoped to Matomo's html.matomoHeatmap class.
  • overflow: hidden and overflow: auto containers get serialised at their visible slice, not their scrollHeight, so anything below the fold inside them is missing. Covered in content cut off.
  • Iframes collapse to zero height and videos render black. The iframes post and the videos post handle each separately.

If you're seeing none of those but the heatmap is empty, the issue is upstream of the screenshot. Common causes: matomo.js isn't writable, so the tracker code never got injected; a strict Content Security Policy is blocking the inline Matomo snippet (move it to an external file or attach a CSP nonce); a WAF or nginx rule is blocking /plugins/HeatmapSessionRecording/configs.php; consent never gets granted, so the tracker never enables; or the SPA dance from the previous section was skipped. Our longer fix-broken-screenshots post walks through the full triage.

What we'd actually do

If you control the site, ship the structural fixes once and forget about them. CORS headers on your CDN, self-hosted fonts, absolute URLs, and a heatmap-only CSS branch that converts sticky headers to relative and expands scroll containers when Matomo reconstructs the page:

css
html.matomoHeatmap .site-header {
  position: relative;
}
 
html.matomoHeatmap .product-grid-wrapper {
  max-height: none;
  overflow: visible;
}

Annoying the first time, zero work afterwards.

SituationBest path
You own the templates and CDNFix CSS, CORS, font hosting, image URLs, and masking in the site. Every future heatmap benefits.
You own the app but the page renders lateAdd the disable/enable or manual captureInitialDom workflow around the route's ready state.
You do not control the templatesUse a capture-time helper, document the snapshot flaws, or budget template changes before presenting the heatmap as evidence.

If you don't control the templates, or you're capturing across client sites where shipping CSS is a multi-week request, the Matomo Heatmap Helper Chrome extension does this at capture time. It embeds images and fonts as base64, expands scroll containers, unsticks headers, rewrites relative URLs, resizes iframes, pauses videos, waits for SPA content, then triggers Matomo's screenshot API and reverses every change after. We built it because we ran into the second half of this guide one too many times on client work. The code is 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 multi-touch 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.

Most of setup is the ten-minute job. The other half is the part nobody tells you about.