---
area: "Learn"
parent: "History chapter"
url: "https://unpoly.com/learn/tracking-page-views"
---
<nav aria-label="Unpoly docs">[All docs](https://unpoly.com/index.md) · [History chapter](https://unpoly.com/learn/history.md)</nav>

# Tracking page views

Web analytics tools like [Matomo](https://matomo.org/) or [Google Analytics](https://analytics.google.com/) count a page view on every page load. With Unpoly the page loads once, and the rest of the session is fragment updates that the tool never sees. To keep your statistics complete, track a page view whenever the browser location changes.

<nav aria-label="Contents">

#### Contents

- [Tracking location changes](#location-changes)
- [Tracking navigation in overlays](#overlays)
- [Tracking rendered fragments instead](#fragments)
- [Tracking every fragment update](#all-fragments)

</nav>

## Tracking location changes {#location-changes}

After Unpoly updates the address bar, it emits an [`up:location:changed`](https://unpoly.com/api/up:location:changed.md) event. Track a page view in a listener:

```js
up.on('up:location:changed', function(event) {
  trackPageView(event.location)
})
```

The event is emitted for every change of the address bar: when a link is followed, when a form submission redirects, or when the user presses the Back button. It is _not_ emitted for the initial page load. Your tracking snippet already counts that, as it does on any site. Nor is it emitted when a link renders the URL that is already in the address bar.

The `trackPageView()` function depends on your analytics tool. All examples on this page assume it exists. For Matomo it would look like this:

```js
function trackPageView(url) {
  _paq.push(['setCustomUrl', url])
  _paq.push(['trackPageView'])
}
```

### Ignoring jumps within the page {#hash-changes}

A click on a link to a `#section` within the page also changes the address bar. If you don't want to count those as page views, check `event.reason`:

```js
up.on('up:location:changed', function(event) {
  if (event.reason !== 'hash') {
    trackPageView(event.location)
  }
})
```

Other reasons are `'push'` for a new history entry, `'replace'` for a changed entry and `'pop'` for the user going back or forward.

## Tracking navigation in overlays {#overlays}

Not every [overlay](https://unpoly.com/api/up.layer.md) has [visible history](https://unpoly.com/learn/history-in-overlays.md). When an overlay without visible history opens or navigates, the address bar does not change and no [`up:location:changed`](https://unpoly.com/api/up:location:changed.md) event is emitted.

When an overlay with visible history closes, the address bar returns to the parent page's URL. This emits [`up:location:changed`](https://unpoly.com/api/up:location:changed.md) and counts another view of the parent page.

To also track navigation within overlays without visible history, observe [`up:layer:location:changed`](https://unpoly.com/api/up:layer:location:changed.md) instead:

```js
// Track when a layer changes its location.
// This includes location changes on the root layer.
up.on('up:layer:location:changed', function(event) {
  trackPageView(event.location)
})

// When an overlay opens, track the overlay's initial location.
up.on('up:layer:opened', function(event) {
  // Don't track overlays that were opened from local string content.
  if (event.layer.location) {
    trackPageView(event.layer.location)
  }
})
```

These listeners do not count the parent page again when an overlay closes.

## Tracking rendered fragments instead {#fragments}

Instead of observing the address bar, you can track a page view whenever a significant fragment is rendered. For example, mark the elements that count as a page with a `[track-page-view]` attribute:

```html
<main track-page-view>
  ...
</main>
```

Then track a page view from a [compiler](https://unpoly.com/learn/enhancing-elements.md):

```js
up.compiler('[track-page-view]', function(element, data, meta) {
  // Don't track duplicate page views if we just reloaded for cache revalidation.
  if (!meta.revalidating) {
    trackPageView(meta.layer.location)
  }
})
```

The compiler runs for the initial page as well as for every later update, so your tracking snippet should not count the initial page load a second time.

### Passing custom dimensions {#dimensions}

A compiler makes it easy to send custom event properties ("dimensions") along with the page view. Encode them in an [`[up-data]`](https://unpoly.com/api/up-data.md) attribute:

```html
<main track-page-view up-data="{ course: 'ruby-basics', page: 1 }">
  ...
</main>
```

The parsed [data object](https://unpoly.com/learn/data.md) is passed to your compiler as a second argument. Forward it to your tracking function:

```js
up.compiler('[track-page-view]', function(element, data, meta) {
  if (!meta.revalidating) {
    trackPageView(meta.layer.location, data)
  }
})
```

## Tracking every fragment update {#all-fragments}

To track _all_ fragment updates, observe the [`up:fragment:loaded`](https://unpoly.com/api/up:fragment:loaded.md) event:

```js
up.on('up:fragment:loaded', function(event) {
  // Don't track revalidation of cached content.
  if (!event.revalidating) {
    trackPageView(event.response.url)
  }
})
```

Polling and [form validation](https://unpoly.com/learn/validation.md) also load fragments, so this listener counts them too. To skip polling, check [`event.request.background`](https://unpoly.com/learn/background-requests.md#detecting), which is set for polling requests. To filter further, inspect other properties of [`event.request`](https://unpoly.com/api/up:fragment:loaded.md#event.request), such as its `{ target }` or `{ layer }`.

<nav aria-label="Reading order">

- [Previous: Restoring history](https://unpoly.com/learn/restoring-history.md)
- [Next: Scrolling & focus](https://unpoly.com/learn/scrolling-and-focus.md)

</nav>

