When working with legacy code, it’s common to see features break after upgrading a library or framework. But more often than not, what’s actually broken isn’t the tool, it’s our understanding of how it was working in the first place.
That’s exactly what happened when we migrated an older Next.js project to the App Router. Google Tag Manager didn’t really break. It had always been fragile. It was working by accident, not by architecture.
The Migration Trap: When Analytics Worked Before — and What Changed
The application was already built with Next.js, but it didn’t use next/link. Because of that, every navigation caused a full page reload. As a result, the native page_view event fired by GTM worked perfectly on every route change.
From an analytics perspective, everything looked solid.
With the introduction of the App Router and next/link, the application gained all the expected benefits: client-side navigation, persistent layouts, persistent scripts, and prefetching. But there was an unexpected side effect. Since a route change is no longer the same as a page load, the native page_view event only fired on the initial load.
At first glance, this looked like a regression, as if the migration had introduced a bug in analytics.
In reality, the migration simply exposed how fragile the analytics setup already was. It had never been designed for client-side routing. It just happened to work before.
The Mental Shift: Pageviews Are No Longer Pages
This is where many developers struggle, especially during migrations.
When an application uses native anchors or forces full reloads, a page_view will always fire. The browser reloads, GTM reinitializes, and analytics behaves as expected.
With the App Router and next/link, navigation is a state transition, not a page load. The application controls routing, and analytics needs to listen to those state changes, not to “pages” in the traditional sense.
In other words, GTM no longer observes page loads. It must observe route transitions.
The Practical Insight: Observing the Only Reliable Signal
Once this mental model is clear, the question becomes straightforward:
What is the single, reliable signal that indicates a navigation happened?
The answer is the pathname.
A pathname change is the only unambiguous navigation event in a client-side routed application. From there, the architectural decision becomes explicit:
Every route change is a pageview.
To declare this in code, we can emit a custom event every time the pathname changes. To keep responsibility centralized and avoid duplicated logic, this is best done inside a provider:
const GTMProvider = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
useEffect(() => {
window.dataLayer.push({
event: 'pageview',
page_path: pathname,
});
}, [pathname]);
return children;
};
export default GTMProvider;
On the GTM side, the fix is equally important and often overlooked:
All tags that previously relied on the native page_view trigger must be updated to listen to this new custom event instead.
Without this change, the implementation looks correct, but nothing fires.
Conclusion: Why This Matters Beyond “Fixing Analytics”
A robust analytics solution is not just a technical concern or an architectural preference. It’s a product, marketing, and conversion problem.
In a market dominated by SPAs, trusting the acquisition and measurement of thousands of users to a system that works by coincidence is a risky decision. The App Router and next/link didn’t break our analytics, they made hidden assumptions visible and exposed a weak architecture.
Once we truly understand the tools we’re working with, the solution becomes not only simpler, but reliable.