'How to track pageviews in RemixJS?

I am building a Remix app, and wanted to record some user analytics in my database based on what page the user was viewing. I also wanted to do so on a route by route basis, rather than just simply the raw URL.

For example: I wanted to know "user viewed URL /emails/123" as well as "user viewed route /emails/$emailId"

This problem could be generalized as "I want to run a piece of server code once per user navigation"

For my tracking I'm assuming users have javascript enabled in their browser.

Solutions I tried:

Record in the loader

This would be something like:

export const loader: LoaderFunction = async ({ request, params }): Promise<LoaderData> => {
  myDb.recordPageVisit(request.url);
}

This doesn't work because the loader can be called multiple times per page visit (eg. after an action is run)

It's possible that there's some value hidden in the request parameter that tells us whether this is an initial page load or if it's a later visit, but if so I couldn't find it, including when I examined the raw HTTP requests.

It's also annoying to have to put this code inside of every loader.

Record the URL in the node code

I'm using @remix-run/node as my base remix server, so I have the escape hatch of setting up node middleware, and I thought that might be a good answer:

  const app = express();
  app.use((req, res, next) => {
    if (req.url.indexOf("_data") == -1) {
      myDb.recordPageVisit(req.url);
    }
    next();
  });

I tried ignoring routes with _data in them, but that didn't work because remix is being efficient and when the user navigates, it is using an ajax-y call to only get the loaderData rather than getting the full rendered page from the server. I know this is the behavior of Remix, but I had not remembered it before I went down this path :facepalm:

As far as I can tell it's impossible to stateless-ly track unique pageviews (ie based purely on the current URL) - you need see the user's previous page as well.

I wondered if referer would allow this to work statelessly, but it appears that the referer is not behaving how I'd hoped: the referer header is already set to the current page in the first loader request for the data for the page. So initial load and load-after-mutation appear identical based on referer. I don't know if this is technically a bug, but it's certainly not the behavior I'd expect.



Solution 1:[1]

I ended up solving this by doing the pageview tracking in the client. To support recording this in the DB, I implemented a route that just received the POSTs when the location changed.

The documentation for react-router's useLocation actually includes this exact scenario as an example.

From https://reactrouter.com/docs/en/v6/api#uselocation:

function App() {
  let location = useLocation();

  React.useEffect(() => {
    ga('send', 'pageview');
  }, [location]);

  return (
    // ...
  );
}

However, that doesn't quite work in remix - the location value is changed after actions (same text value, but presumably different ref value). So I started saving the last location string seen, and then only report a new pageview when the location string value has changed.

So after adding that stateful tracking of the current location, I landed on:

export default function App() {
  // ...other setup omitted...
  const [lastLocation, setLastLocation] = useState("");
  let location = useLocation();
  const matches = useMatches();

  useEffect(() => {
    if (lastLocation == location.pathname) {
      return;
    }
    // there are multiple matches for parent route + root route, this 
    // will give us the leaf route
    const routeMatch = matches.find((m) => m.pathname == location.pathname);
    setLastLocation(location.pathname);
    fetch("/api/pageview", {
      body: JSON.stringify({ 
        url: location.pathname,
        // routeMatch.id looks like: "/routes/email/$emailId"
        route: routeMatch?.id }),
      method: "POST",
    }).then((res) => {
      if (res.status != 200) {
        console.error("could not report pageview:", res);
      }
    });
  }, [location]);

The matches code is not necessary for tracking just raw URLs, but I wanted to extract the route form (eg /emails/$emailId), and matches.id is a close match to that value - I strip "routes/" serverside. Matches docs: https://remix.run/docs/en/v1/api/remix#usematches

Client side pageview tracking is a bit annoying since clients are flaky, but for the current remix behavior I believe this is the only real option.

Solution 2:[2]

Did it a different way for routes, remix is funny cuz of the whole parent route thing * so I use a route logger

Beans.io

https://www.npmjs.com/package/beansio

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 user4920718