Skip to main content

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 seesWhat it means
the prefetch or prerendera GET with a full 200 HTML bodya page nobody has opened
the click afterwardsnothing at allthe 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 pagePrerendered page
Server-side, matomo.trackThe 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, @matomoScriptThe 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.deliveryType is navigational-prefetch for a page served out of a prefetch.
  • PerformanceNavigationTiming.activationStart is 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 POST needs no preflight, so any page anywhere could make its visitors beacon this route, and that page writes the URL too. So the request's Origin header must be your application's origin, or, where a browser sends no Origin, Sec-Fetch-Site must be same-origin; anything else is refused with 403 and 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.