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. The router object is truthy and the target URL is what you expect.
  • However, immediately after router.push, the browser URL does not change and useSearchParams does not update, so the result-fetching useEffect never re-runs.
  • The symptom appears whether useRouter is imported from next-intl's @/i18n/navigation or from native next/navigation. It does not reproduce in dev or with Turbopack.
  • Because all the navigation calls funnel through a single updateUrl callback, replacing router.push(target) with history.pushState({}, '', target); dispatchEvent(new PopStateEvent('popstate')) restores interactivity. useSearchParams reacts to popstate, which is enough to cascade the existing useEffect.
  • 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"> to 96 did 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:

#HypothesisResult
1next-intl's wrapped useRouter is at faultDid not match. Replacing the import with native next/navigation's useRouter showed the same symptom.
2Moving 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.
3A specific 16.2.x patch version is responsibleThe bump from 16.2.1 to 16.2.6 (plus npm audit fix) did not change the symptom.
4Does it reproduce in dev mode (Turbopack)?It does not. npm run dev works correctly.
5Does next build --turbopack for production help?No, same symptom.
6Bypass 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 usePathname from next/navigation (native) rather than from next-intl for this workaround. The next-intl variant intentionally strips the locale prefix (it returns /search even on /en/search), so history.pushState with that path would drop /en/ for users on the secondary locale. The native usePathname returns the actual URL path, which keeps the prefix intact.
  • history.pushState bypasses Next.js's RSC navigation path. router.prefetch and 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> or router.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.ts to the project root (helpful for non-src/ setups)
  • Insert router.refresh() before router.replace()
  • Use window.location.href = url for 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 (with HOSTNAME=localhost and NEXT_PUBLIC_API_URL matching production) tends to be the fastest path to a reliable repro.
  • .next/standalone/ does not include public/ or .next/static/ automatically. They have to be copied manually, as the Next.js documentation notes.
  • Next.js 16 renames middleware.ts to proxy.ts. In src/-based projects, src/proxy.ts is recognized by the framework. The "move it to the root" suggestion from older discussions does not apply to src/-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.