# 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.

Published: 2026-06-10
Categories: Marketing Analytics, Matomo
Canonical: https://martez.io/blog/how-to-set-up-matomo-heatmaps

---

<Callout type="tip" title="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.

</Callout>

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.

<FlowSteps
  caption="Where heatmap screenshots usually break"
  steps={[
    {
      title: "Visitor browser",
      detail: "tracker captures the page in the visitor's session",
      branch: { label: "breaks here", title: "Late JS or a route that isn't ready yet" },
    },
    { title: "Matomo storage", arrow: "sanitized DOM and interactions" },
    {
      title: "Heatmap viewer reconstructs the page",
      branch: { label: "breaks here", title: "Sticky layout or nested scroll containers" },
    },
    {
      title: "CSS, images, fonts load from your site or CDN",
      branch: { label: "breaks here", title: "CORS, mixed content, missing assets" },
    },
  ]}
/>

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](https://plugins.matomo.org/HeatmapSessionRecording) 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
```

<Callout type="tip" title="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.
</Callout>

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.

<Callout type="info" title="Consent changes the tracker flow" collapsable>
If you use Matomo tracking consent, call `_paq.push(['requireConsent'])` before tracking. If Matomo remembers consent, call `_paq.push(['rememberConsentGiven'])` after the user opts in. If your CMP remembers consent, call `_paq.push(['setConsentGiven'])` on every page after consent.

If you use cookie consent rather than tracking consent, use `requireCookieConsent`, `setCookieConsentGiven`, `rememberCookieConsentGiven`, and `forgetCookieConsentGiven` instead.

If Heatmap & Session Recording itself must stay off until consent, call `_paq.push(['HeatmapSessionRecording::disable'])` early and `_paq.push(['HeatmapSessionRecording::enable'])` only after consent is granted. Disable it again when consent is withdrawn.
</Callout>

## Create your first heatmap

In Heatmaps → Manage Heatmaps → Create New Heatmap:

| Field | Recommended first value | Why | When to change it |
| --- | --- | --- | --- |
| Name | `Home / May 2026` | You will need to find it later. | Add campaign, locale, or template names when many pages share a layout. |
| Target page rule | `equals simple` for a single canonical page | It 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 URL | The clean canonical URL | This 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 rate | 100% on low-traffic pages | You 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 limit | A concrete number you can review | Keeps a runaway heatmap from collecting forever. | Raise it for seasonal pages or low-traffic pages that need more time to reach significance. |
| Breakpoint widths | Use Matomo's desktop, tablet, and mobile views | Matomo 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 elements | Cookie banners, popups, chat widgets, survey overlays | Element 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 snapshot | Leave off for a normal static page | Matomo 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.

<VersionBadge items={["Heatmap & Session Recording 5.1.0+ manual snapshot", "Matomo 5.4.0+ copy heatmap", "Cloud 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.

<Callout type="question" title="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.
</Callout>

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

| Situation | Recapture workflow |
| --- | --- |
| Static page, assets restored | Delete 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 app | Disable 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](/blog/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](/blog/matomo-heatmap-images-not-loading) 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](/blog/matomo-heatmap-relative-urls-not-loading) 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](/blog/matomo-heatmap-sticky-header-repeating) 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](/blog/matomo-heatmap-content-cut-off).
- Iframes collapse to zero height and videos render black. The [iframes post](/blog/matomo-heatmap-iframes-collapsed) and the [videos post](/blog/matomo-heatmap-videos-not-loading) 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](/blog/fix-broken-matomo-heatmap-screenshots) 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.

| Situation | Best path |
| --- | --- |
| You own the templates and CDN | Fix CSS, CORS, font hosting, image URLs, and masking in the site. Every future heatmap benefits. |
| You own the app but the page renders late | Add the disable/enable or manual `captureInitialDom` workflow around the route's ready state. |
| You do not control the templates | Use 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](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) 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](https://github.com/martez-io/matomo-heatmap-helper).

[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=how-to-set-up-matomo-heatmaps) 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](/signup?utm_source=martez&utm_medium=blog&utm_campaign=how-to-set-up-matomo-heatmaps) if that's relevant.

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