> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tracking page navigation in a SPA

> Capture product_view and collection_view events from a single-page-app storefront (Hydrogen, Next.js, Remix) using router lifecycle hooks and navigator.sendBeacon.

In a server-rendered Shopify theme, every product or collection page is a full page load — `DOMContentLoaded` is a reliable trigger for `product_view` and `collection_view` events. In a single-page app (Hydrogen, Next.js Commerce, Remix, custom React/Vue storefronts), navigation is client-side and `DOMContentLoaded` only fires once. You need to hook into the router instead.

This guide assumes you've already set up the `enqueue()` helper and session/attribution helpers from [Sending events with `navigator.sendBeacon`](/tracking-api/guides/send-beacon).

## Prerequisites

* A working `enqueue()` function and a flush-on-`pagehide` listener.
* `getSessionId()` and `getAttributionToken()` helpers.
* The Layers storefront access token available to your client bundle (typically via a build-time env var like `NEXT_PUBLIC_LAYERS_TOKEN`).

## React Router / Next.js App Router

Use a top-level effect that re-runs when the pathname changes.

```jsx theme={null}
'use client';

import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

export function LayersTracker({ product, collection }) {
  const pathname = usePathname();
  const search = useSearchParams();

  useEffect(() => {
    if (product) {
      enqueue({
        event_id: crypto.randomUUID(),
        event_type: 'product_view',
        timestamp: new Date().toISOString(),
        session_id: getSessionId(),
        product_id: product.id,
        attribution_token: getAttributionToken(),
      });
    } else if (collection) {
      enqueue({
        event_id: crypto.randomUUID(),
        event_type: 'collection_view',
        timestamp: new Date().toISOString(),
        session_id: getSessionId(),
        collection_handle: collection.handle,
      });
    }
  }, [pathname, search, product?.id, collection?.handle]);

  return null;
}
```

Drop `<LayersTracker product={...} />` into your product page layout and `<LayersTracker collection={...} />` into your collection page layout. The effect dependency array re-fires the event when the route changes between two products or two collections.

## Remix / React Router v6

Hook into `useLocation()` the same way.

```jsx theme={null}
import { useLocation } from '@remix-run/react';
import { useEffect } from 'react';

export function ProductViewTracker({ product }) {
  const location = useLocation();

  useEffect(() => {
    if (!product) return;
    enqueue({
      event_id: crypto.randomUUID(),
      event_type: 'product_view',
      timestamp: new Date().toISOString(),
      session_id: getSessionId(),
      product_id: product.id,
      attribution_token: getAttributionToken(),
    });
  }, [location.pathname, product?.id]);

  return null;
}
```

## Vue Router

Subscribe to the global `afterEach` guard.

```js theme={null}
import { useRouter } from 'vue-router';

const router = useRouter();

router.afterEach((to) => {
  const productId = to.meta?.layers?.productId;
  const collectionHandle = to.meta?.layers?.collectionHandle;

  if (productId) {
    enqueue({
      event_id: crypto.randomUUID(),
      event_type: 'product_view',
      timestamp: new Date().toISOString(),
      session_id: getSessionId(),
      product_id: productId,
      attribution_token: getAttributionToken(),
    });
  } else if (collectionHandle) {
    enqueue({
      event_id: crypto.randomUUID(),
      event_type: 'collection_view',
      timestamp: new Date().toISOString(),
      session_id: getSessionId(),
      collection_handle: collectionHandle,
    });
  }
});
```

Set `meta.layers` on each route when you define it:

```js theme={null}
{
  path: '/products/:handle',
  component: ProductPage,
  meta: {
    layers: () => ({ productId: currentProduct.value?.id }),
  },
}
```

## Capturing the attribution token after every Search / Browse / Blocks call

The attribution token rotates per request. Update it from your data fetcher so subsequent clicks and conversions carry the right one:

```js theme={null}
async function searchProducts(query) {
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();
  setAttributionToken(data.attributionToken); // store for later events
  return data;
}
```

If your storefront fetches Search, Browse, and Blocks data on the server (e.g., Next.js server components, Remix loaders), forward the `attributionToken` to the client via the page's props or a small `<script>` tag so the tracker can pick it up.

## Why `pagehide` still matters in a SPA

Even though your app doesn't do full page reloads on internal navigation, the browser still fires `pagehide` when the user:

* Navigates to an external site.
* Closes the tab or window.
* Goes to the back/forward cache on mobile Safari.

The unload-flush listener you set up in the [`navigator.sendBeacon` guide](/tracking-api/guides/send-beacon#batching-events) handles all of these. Keep it.

## Next steps

* [Event types](/tracking-api/event-types) — full schema reference.
* [Payload examples](/tracking-api/payload-examples) — copy-pasteable bodies.
