I launched a small services site this week, care.joehahn.dev, and I wanted exactly one number out of it: how many people sent the contact form. Not a funnel. Not a heatmap. One count, from my own Matomo, without setting a single cookie.
It took four pieces. A shared loader, a queue trick for cookieless mode, three lines of CSP, and a goal that watches the thank-you page.
One Matomo site for many hostnames
My Matomo lives at stats.abject.dev, a 5.14.0 install on DreamHost shared hosting. It's meant for a couple dozen sites I plan to track. Giving each one its own site ID means a couple dozen snippets to keep in sync, so I did the lazy thing instead. Everything reports to site 1, and a custom dimension records which hostname sent the hit.
The loader every site includes, t.js:
(function () {
if (window.__matomoLoaded) { return; }
window.__matomoLoaded = true;
var base = 'https://stats.abject.dev/';
var _paq = window._paq = window._paq || [];
_paq.push(['setTrackerUrl', base + 'matomo.php']);
_paq.push(['setSiteId', '1']);
_paq.push(['setCustomDimension', 1, window.location.hostname]);
_paq.push(['enableLinkTracking']);
_paq.push(['trackPageView']);
var s = document.createElement('script');
s.async = true;
s.src = base + 'matomo.js';
document.head.appendChild(s);
})();
Dimension 1 is called Hostname, with action scope. To see one site, I build a segment on Hostname == care.joehahn.dev.
The tradeoff is real. Per-site settings like excluded query parameters or site search are shared across all of them. For sites this small I don't miss them. If one of them grows up, it gets its own ID.
One more detail. Matomo's file integrity check complains about files it didn't ship, so t.js actually lives in misc/user/, which the check ignores. A one-line rewrite serves it at /t.js:
RewriteRule ^t\.js$ misc/user/t.js [L]
Cookieless, by queuing first
Matomo reads _paq as a queue. Anything already in the array when the tracker boots gets applied before the first page view. So the site loads a tiny script of its own ahead of the shared loader:
(function () {
var _paq = window._paq = window._paq || [];
_paq.push(["disableCookies"]);
_paq.push(["setDoNotTrack", true]);
var s = document.createElement("script");
s.async = true;
s.src = "https://stats.abject.dev/t.js";
document.head.appendChild(s);
})();
disableCookies lands before trackPageView, so the first hit is already cookieless. You can see it in the request: _id is empty. setDoNotTrack means a browser with DNT on sends nothing at all.
Without cookies, Matomo can't recognize a returning visitor across days. For "did someone send the form," I don't care. And there's no consent banner to build, because there's nothing to consent to.
The CSP lines
The site ships a strict Content-Security-Policy. Matomo needs three allowances, all on the stats host:
script-src 'self' https://stats.abject.dev
img-src 'self' https://stats.abject.dev
connect-src 'self' https://stats.abject.dev
script-src loads t.js and matomo.js. connect-src covers the tracking request, which goes out as a POST with sendBeacon or fetch. img-src covers the fallback pixel. Leave out connect-src and nothing breaks visibly. The hits just quietly don't arrive.
The goal: watch the thank-you page
The contact form posts to itself. On success it answers 303 See Other with a redirect to /contact/thanks/. That redirect is the whole trick. A successful submission is also a page view of a URL nobody visits any other way, and Matomo can turn that into a goal without any custom event code.
In Matomo, Goals, Add a new goal:
- Triggered when: visit a given URL
- Pattern: contains
care.joehahn.dev/contact/thanks
The hostname is in the pattern on purpose. With every site sharing site 1, a pattern of just /contact/thanks would count every site's thank-you page as a lead.
Two small things make the thanks page behave. It's noindex and disallowed in robots.txt, so nobody lands on it from a search. And the form's POST-then-redirect means a browser refresh re-requests the thanks page, not the form, so nobody double-submits.
What I'd do differently
This counts a floor, not a total. Anyone with Do Not Track on, or an ad blocker that knows the Matomo script names, sends the form and never shows up. The inbox is the real count. Matomo tells me where the ones it can see came from.
The honeypot is a known gap. When a bot fills the hidden field, the form pretends it worked and redirects to the thanks page too, so no mail is sent but the goal could fire. In practice most form bots don't run JavaScript, so they never load the tracker. I haven't measured that yet. It's one day old and has zero real leads.
Also, if your own visits never show up, check before you debug. When I first set this up I spent a while convinced it was broken. It wasn't. Matomo was excluding me as a logged-in admin, and I had Do Not Track on anyway.
Links: Matomo FAQ: going cookieless · Matomo JavaScript tracking guide