Problem
I started treating this as a real production risk the moment I saw the same symptom described publicly: a Next.js 16 app looks fine on first load, but after you navigate away and come back, the page with a map, player, or other imperative widget starts hanging, duplicating DOM, or blowing up the tab.
That report showed up in a recent r/nextjs thread about cacheComponents, where the original problem looked like a JSON-LD crash at first. The more useful detail was in the follow-up: teams were seeing the same thing with video players and map integrations once cacheComponents was enabled and routes started reappearing instead of remounting.
That lines up with what the official docs say. With cacheComponents on, Next.js uses React's Activity behavior for navigation. In plain English, old routes are often hidden and preserved, not torn down. That is great for forms and open accordions. It is a very different environment for libraries like Mapbox GL, Leaflet, Google Maps wrappers, Three.js canvases, and custom media players that assume a clean mount and unmount lifecycle.
If you are already working through caching behavior in the App Router, my earlier post on Next.js use cache Returning Stale Data After Deploy is the right companion read. The two bugs show up in the same upgrade window.
Why It Happens
The failure is usually not "Mapbox is broken" or "cacheComponents is broken" in isolation. The problem is the contract between them.
The relevant part of the Next.js docs is this: when cacheComponents is enabled, client-side navigation can keep a previous route in a hidden state. React cleans up effects when the route is hidden, then recreates them when it becomes visible again. That sounds safe, but a lot of imperative libraries do more than subscribe to React effects:
- they mutate the DOM under their container
- they attach observers and timers outside React
- they retain WebGL or media state that does not like partial teardown
- they expect the container dimensions and visibility state to be stable when they initialize
That is why the bug often looks random. The first load works. A forward navigation works. Then the back navigation or second visit surfaces the issue because the preserved tree is not the same thing as a fresh mount.
I have seen two common failure modes:
1. The widget never fully cleans up
The cleanup function removes some listeners, but not the actual instance. On reappearance, the component creates a second instance on top of the preserved DOM shell.
2. The widget initializes while hidden
Many map and canvas libraries read container dimensions during setup. If the route comes back in a hidden state first, the library caches zero-width or stale layout values and never fully recovers.
The Fix
The practical fix is to stop letting the third-party widget participate in preserved route state unless you know it is safe.
I use three rules:
1. Isolate the imperative library in a tiny client wrapper
Do not scatter Mapbox or player setup across a bigger page component. Keep one obvious mount point and one obvious cleanup path.
'use client'
import { useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
import mapboxgl from 'mapbox-gl'
type Props = {
center: [number, number]
zoom?: number
}
export function ClientMap({ center, zoom = 12 }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const pathname = usePathname()
useEffect(() => {
if (!containerRef.current || mapRef.current) return
mapRef.current = new mapboxgl.Map({
container: containerRef.current,
style: 'mapbox://styles/mapbox/dark-v11',
center,
zoom,
})
return () => {
mapRef.current?.remove()
mapRef.current = null
}
}, [center, zoom, pathname])
return <div key={pathname} ref={containerRef} className="h-[420px] w-full" />
}
The important part is not the exact code. The important part is that you force one real lifecycle boundary around the widget and give yourself a predictable place to destroy it.
2. Key the widget when route identity changes
If the preserved route behavior keeps biting you, key the widget by pathname or a stable route segment so React creates a fresh subtree when the user returns.
This is a targeted reset. I would rather reset one map component than disable useful navigation behavior across the whole app.
3. Move fragile libraries behind dynamic import if needed
If a library is touching window, layout, or observers too early, move it behind a client-only boundary:
import dynamic from 'next/dynamic'
const ClientMap = dynamic(
() => import('./client-map').then((mod) => mod.ClientMap),
{ ssr: false }
)
That does not solve cleanup by itself, but it removes server-side noise and makes the widget easier to reason about.
What I Check Before Shipping
My release checklist is simple:
- navigate to the page from another route
- leave the page and come back with browser back
- revisit the same page from a different path
- resize the viewport after the route reappears
- confirm there is only one canvas or map root in the DOM
If the widget fails any of those, I stop treating it as a normal React component.
The official docs for cacheComponents and React Activity are worth rereading here. The recent community thread on cacheComponents and route crashes is also useful because it shows this is not theoretical.
CTA
If your Next.js app is hitting preserved-route bugs, caching regressions, or tricky production-only navigation issues, see my services. I fix this kind of App Router problem for client projects without papering over the root cause.
