Prefetched and prerendered pages
A browser that supports Speculation Rules
loads a page before anybody has asked for it. With eagerness: moderate that
happens when the pointer merely rests on a link. A prefetch rule fetches the page; a
prerender rule also renders it in the background and runs its scripts.
For analytics that is two problems, not one, and they pull in opposite directions:
| What the server sees | What it means | |
|---|---|---|
| the prefetch or prerender | a GET with a full 200 HTML body | a page nobody has opened |
| the click afterwards | nothing at all | the browser shows the page it already holds |
Count the speculative load and every hovered link becomes a page view. Skip it and the visit that follows disappears. The package does both halves.
What each mode counts
A page counts once, when the reader opens it, and a page that is only prefetched or prerendered counts in neither mode:
| Prefetched page | Prerendered page | |
|---|---|---|
Server-side, matomo.track | The prefetch is skipped, and @matomoPrefetchPageView reports the page when the reader opens it. | The prerender is skipped, and @matomoPrefetchPageView reports the page when the reader opens it. |
Client-side, @matomoScript | The page runs its scripts when the reader opens it, and matomo.js counts it then. | The page runs its scripts while it is prerendered, so the snippet loads matomo.js only when the reader opens it. |
Without @matomoPrefetchPageView, a page opened from a prefetch or a prerender is not
counted server-side at all.
The speculative request is skipped
middleware.skip_prefetch is true by default. A request whose Sec-Purpose or
Purpose header carries prefetch is not tracked, the same way a wire:navigate
prefetch is not.
Both headers are read because they are not interchangeable in the field: Sec-Purpose
is the specified one, Purpose is what Chrome sent before it and what some proxies
still send. The match is on the token, so Sec-Purpose: prefetch;prerender, the header
of a prerender, is covered as well.
The tracking gate refuses every other hit of such a request as well, through
tracking.skip_prefetch, which is also true by default: an event or a page view your
application sends while rendering a prefetched page, such as the 404 behind a broken link,
describes a page nobody has seen. The middleware's switch decides whether it builds a page
view; the gate's decides whether any hit of the request goes out.
Set both to false if you would rather count speculative loads, and leave the beacon below
off then: it reports the page a second time when the reader opens it.
// config/matomo-analytics.php
'middleware' => [
'skip_prefetch' => true,
],
'tracking' => [
'skip_prefetch' => true,
],
The page reports itself when it is opened
This half is for page views the server counts, through matomo.track. Opt in and put the
directive in your layout:
// config/matomo-analytics.php
'prefetch_beacon' => [
'enabled' => true,
],
{{-- resources/views/layouts/app.blade.php --}}
@matomoPrefetchPageView
The directive renders a small inline script that asks the browser where the document came from, and beacons the page view only for a page the server did not see:
PerformanceNavigationTiming.deliveryTypeisnavigational-prefetchfor a page served out of a prefetch.PerformanceNavigationTiming.activationStartis above zero for a prerendered page the reader has opened.
A prerendered page runs the script while it is still in the background. As long as
document.prerendering
is true, the script waits for the prerenderingchange event, which the browser fires when
the reader opens the page, so a prerender that is never opened sends nothing. An ordinary
load has already been counted server-side, so it sends nothing; an engine that reports
neither value sends nothing either.
A page that runs matomo.js needs none of this, as the next section explains. The script sends nothing where matomo.js or a Tag Manager container is on the page, so a layout that carries both directives counts the view once.
Running a strict Content Security Policy? The directive takes a nonce, like the others:
@matomoPrefetchPageView($nonce)
matomo.js on a prerendered page
A prefetched page runs its scripts when the reader opens it, not when the browser fetches
it, so @matomoScript counts that view itself.
A prerendered page runs them in the background, before anybody has opened it, and
matomo.js holds its page view back only for the prerender visibility state of the older
<link rel="prerender">, which a prerender from Speculation Rules does not report. So
@matomoScript fills the _paq queue at once, as on any page, and inserts matomo.js when
the browser fires prerenderingchange. The page view waits in the queue until the reader
opens the page, and a prerender that is never opened loads no tracker at all. A Tag Manager
container is loaded the same way, because its Pageview trigger fires as soon as it loads.
A page that is not prerendered loads the tracker at once, as before, and so does every browser that does not prerender.
What the endpoint accepts
POST matomo-analytics/page-view takes the page URL and its title. The route is always
registered — toggling the feature never needs a route-cache rebuild — and the controller
answers 404 while the feature is off.
- The request must come from one of your pages. A form-encoded cross-origin
POSTneeds no preflight, so any page anywhere could make its visitors beacon this route, and that page writes the URL too. So the request'sOriginheader must be your application's origin, or, where a browser sends noOrigin,Sec-Fetch-Sitemust besame-origin; anything else is refused with403and records nothing. - The URL must be on this application's own origin, compared on scheme, host and port.
It names the page the view is filed under, and a URL from somewhere else is refused with
422. - The title is optional and bounded. Without one the page is named by its path, which
is what the server-side middleware does for a response that carries no
<title>. - The hit goes through the normal tracking gate, so bots, opt-out, Do Not Track and excluded routes apply exactly as they do everywhere else.
- The route is throttled per
prefetch_beacon.throttle, keyed on the visitor's real address rather than on a proxy's.
Like the Web Vitals route, it sits in no middleware group, because sendBeacon() carries
no CSRF token. No session is started there, so tracking.track_authenticated and
tracking.except_abilities see a guest on this path. The read-only session pair from the
hit beacon in prefetch_beacon.middleware applies those
rules without writing the session; ['web'] applies them too, writes the session, and needs a
CSRF exemption for the route on your side.