This article was co-written with a generative AI. Facts have been checked against official documentation where possible, but errors may remain. Please verify against primary sources before making important decisions.
Summary
Based on what I observed:
- On a Next.js 16.2.x + next-intl 4.9.x setup, a search page's interactive controls (page size selector, facet selection, keyword form, pagination) stop working after a production build (
next build). Development mode is unaffected. - The handlers fire, the URL is built correctly, and
router.push(target)is invoked. Therouterobject is truthy and the target URL is what you expect. - However, immediately after
router.push, the browser URL does not change anduseSearchParamsdoes not update, so the result-fetchinguseEffectnever re-runs. - The symptom appears whether
useRouteris imported from next-intl's@/i18n/navigationor from nativenext/navigation. It does not reproduce in dev or with Turbopack. - Because all the navigation calls funnel through a single
updateUrlcallback, replacingrouter.push(target)withhistory.pushState({}, '', target); dispatchEvent(new PopStateEvent('popstate'))restores interactivity.useSearchParamsreacts topopstate, which is enough to cascade the existinguseEffect. - Related discussion: vercel/next.js#51782
Context
On a search-oriented web app, I was updating an Elasticsearch index and posting a small announcement to the frontend (Next.js 16.2.x, next-intl 4.9.x). The announcement was a single Markdown file under content/{ja,en}/news/<date>.md. The plan was to push the file, rebuild the frontend, and invalidate the CDN.
After rebuilding and deploying, a user reported that the search page's facets and page-size selector were no longer working. The page itself rendered fine and the initial set of 24 results came in, but:
- Changing
<select id="size">to96did not change the result count - Typing a keyword and pressing Enter did not change the URL
- Clicking facet values did not filter
- Pagination did not advance
The browser DevTools console was silent — no pageerror, no hydration warning, no console.error. Network tab showed no new API requests after any interaction.
Investigation
The interaction code on the page follows a fairly standard App Router pattern:
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const updateUrl = useCallback((updates) => {
const params = new URLSearchParams(searchParams.toString())
// (apply updates to params)
router.push(`${pathname}?${params.toString()}`)
}, [searchParams, router, pathname])
const handleSizeChange = useCallback((newSize) => {
setSize(newSize)
updateUrl({ size: newSize, page: 1 })
}, [setSize, updateUrl])
I added console.log calls in handleSizeChange, updateUrl, and right before router.push, then drove a Playwright headless browser to pick 96 from the <select> and captured everything it emitted:
[DBG handleSizeChange] newSize= 96
[DBG updateUrl] updates= {size: 96, page: 1}
[DBG router.push] target= /search?fc-source=...&size=96&page=1 router?= true
So the handler fires, the callback runs, the router reference is truthy, and the target URL is correct. Yet page.url() immediately after that returns the unchanged URL and no API request is observed.
I then went through hypotheses one at a time:
| # | Hypothesis | Result |
|---|---|---|
| 1 | next-intl's wrapped useRouter is at fault | Did not match. Replacing the import with native next/navigation's useRouter showed the same symptom. |
| 2 | Moving middleware out of src/ to project root fixes it (per the related GitHub thread) | In a src/-based project, moving it to the root made the build succeed but routing returned 404 across the app, so this option was not applicable. |
| 3 | A specific 16.2.x patch version is responsible | The bump from 16.2.1 to 16.2.6 (plus npm audit fix) did not change the symptom. |
| 4 | Does it reproduce in dev mode (Turbopack)? | It does not. npm run dev works correctly. |
| 5 | Does next build --turbopack for production help? | No, same symptom. |
| 6 | Bypass router.push with history.pushState({}, '', target); dispatchEvent(new PopStateEvent('popstate')) | Works. URL changes, useSearchParams updates, the existing useEffect re-runs and the page reflects the new state. |
The behavior is asymmetric between development and production builds, which suggests something in the build / code-split / optimization pipeline is interacting with the App Router's navigation path. I have not been able to pin down the exact mechanism, so I'm avoiding strong claims here.
Workaround
Because the only navigation call in the page is the updateUrl callback, fixing that one function restores handleSizeChange, handleFacetSelect, handleSearch, and handlePageChange together.
// Workaround for an observed issue where Next.js 16's `useRouter().push()`
// is a silent no-op in production builds (the handler fires and the target
// URL is correct, but the browser URL does not change and `useSearchParams`
// does not update, so the data-fetching useEffect never re-runs).
// - Symptom does not reproduce in dev / Turbopack.
// - Same symptom whether useRouter comes from next-intl or next/navigation.
// - Reference: https://github.com/vercel/next.js/discussions/51782
// Mitigation: use `history.pushState` to change the URL and dispatch a
// `popstate` event so `useSearchParams` picks up the new value, which
// cascades into the existing fetch useEffect. Once Next.js fixes this
// upstream, revert to plain `router.push(target)`.
const updateUrl = useCallback(
(updates: Record<string, string | string[] | number | null>) => {
const params = new URLSearchParams(searchParams.toString())
Object.entries(updates).forEach(([key, value]) => {
params.delete(key)
if (value !== null && value !== undefined && value !== '') {
if (Array.isArray(value)) {
value.forEach((v) => params.append(key, v))
} else {
params.set(key, String(value))
}
}
})
const target = `${pathname}?${params.toString()}`
if (typeof window !== 'undefined') {
window.history.pushState({}, '', target)
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
router.push(target)
}
},
[searchParams, router, pathname]
)
Both headless and real-browser tests after this change show the size selector, facet selections, and pagination working as expected. The keyword form goes through the same updateUrl callback, so it recovers too.
Caveats
- Prefer
usePathnamefromnext/navigation(native) rather than from next-intl for this workaround. The next-intl variant intentionally strips the locale prefix (it returns/searcheven on/en/search), sohistory.pushStatewith that path would drop/en/for users on the secondary locale. The nativeusePathnamereturns the actual URL path, which keeps the prefix intact. history.pushStatebypasses Next.js's RSC navigation path.router.prefetchand similar optimizations do not apply to the same transition. For "stay on the same route, only change the query string" — which is what search facets do — this is acceptable. For cross-route navigation, prefer<Link>orrouter.push.- This is a mitigation, not a fix. I left a comment in the code so it's easy to revert to plain
router.push(target)once Next.js addresses the underlying issue.
Related reports
vercel/next.js#51782 collects reports of the same "dev works, production router.push does nothing" pattern across Next.js 15.0.3, 15.2.2, 15.3.1, and 15.3.2. The most commonly proposed mitigations in the thread are:
- Move
middleware.tsto the project root (helpful for non-src/setups) - Insert
router.refresh()beforerouter.replace() - Use
window.location.href = urlfor hard navigation (abandons SPA semantics)
For a src/-based Next.js 16 + next-intl project, the middleware (proxy.ts in Next.js 16) location change is not applicable, and history.pushState + popstate was the lowest-blast-radius option in my case.
Side notes
A few things I bumped into during the investigation that may be useful for someone hitting the same class of problem.
- When dev and production builds diverge, reproducing locally with
next build && node .next/standalone/server.js(withHOSTNAME=localhostandNEXT_PUBLIC_API_URLmatching production) tends to be the fastest path to a reliable repro. .next/standalone/does not includepublic/or.next/static/automatically. They have to be copied manually, as the Next.js documentation notes.- Next.js 16 renames
middleware.tstoproxy.ts. Insrc/-based projects,src/proxy.tsis recognized by the framework. The "move it to the root" suggestion from older discussions does not apply tosrc/-based projects. - This is a silent failure, so error-string searches won't surface it. Symptom-phrase searches (
router.push no-op,router.push not working in production) are more productive.
Open questions
The workaround is keeping the page functional, but I have not yet identified the underlying cause inside the framework. Looking deeper at where exactly useRouter().push becomes a no-op (the AppRouterContext dispatch, a stuck transition, or a particular Suspense / RSC boundary interaction) would require digging into Next.js itself. If I find an upstream commit that addresses the same behavior, I'll update this post.



Comments
…