Fix Invariant: attempted to hard navigate to the same URL
Reproduced on Next.js 13.1.0, 13.2.0 and 16.4.3: why a NextResponse.next() middleware broke the 404 page, why the same invariant still fires after a redeploy, and the catch that stops it.
TL;DR#
- What it means: the Pages Router could not load the route it was navigating to, decided a full page load was the safe answer, and found the target was the URL already open. It throws instead of reloading, to avoid an infinite refresh loop.
- The NEXT-435 case (an empty middleware plus a custom 404 page, in dev): a router bug present in Next.js 13.1.0, fixed by PR #46303. The first stable release that contains it is 13.2.0; on 13.x, upgrade to 13.5.9, which also patches the middleware bypass CVE-2025-29927.
- The case you can still hit today:
router.push(window.location.href)when the/_next/datarequest fails, typically a tab left open across a redeploy. Catch the rejected promise and callwindow.location.reload(). - Reproduced on Next.js 13.1.0, 13.2.0 and 16.4.3 in the
node:22-slimDocker image (Node v22.23.3) with headless Chrome, 24 October 2026.
The error#
The GitHub issue vercel/next.js #44293, tracked internally as NEXT-435, starts from the smallest middleware possible:
import { NextResponse } from 'next/server';
export function middleware(request) {
// some rewrite logic for specific requests
// all other requests pass-trough
return NextResponse.next();
}With that file in place, opening any URL that does not exist in next dev shows the dev overlay with this error. The reproduction below, on Next.js 13.1.0, prints it from the browser:
--- GET http://localhost:32768/page-does-not-exist
[navigated] http://localhost:32768/page-does-not-exist
[data] 200 /_next/data/development/page-does-not-exist.json
[data] 200 /_next/data/development/page-does-not-exist.json
[console.error] Error: Failed to load script: /_next/static/chunks/pages/page-does-not-exist.js
[pageerror] Error: Invariant: attempted to hard navigate to the same URL /page-does-not-exist http://localhost:32768/page-does-not-exist
--- final: http://localhost:32768/page-does-not-exist | Custom 404 | overlay shows 'Unhandled Runtime Error': trueThe two URLs at the end of the message are the router's path (/page-does-not-exist) and location.href. The reporter traced the start of the regression to v13.0.7-canary.5; that version range is theirs, not re-tested here.
Why it happens#
The throw lives in one place, handleHardNavigation in packages/next/src/shared/lib/router/router.ts, the client router of the Pages Router. A code search of the vercel/next.js repository finds the message in that file only, so the App Router does not produce it. The current canary source reads:
function handleHardNavigation({
url,
router,
}: {
url: string
router: Router
}) {
// ensure we don't trigger a hard navigation to the same
// URL as this can end up with an infinite refresh
if (url === addBasePath(addLocale(router.asPath, router.locale))) {
throw new Error(
`Invariant: attempted to hard navigate to the same URL ${url} ${location.href}`
)
}
window.location.href = url
}The router calls it from handleRouteInfoError when it cannot load a page or its data. The comment there lists the reasons it assumes: the page does not exist, it lives in a different zone, or an internal error happened, "So, doing a hard reload is the proper way to deal with this." A hard reload of a different URL is a normal full page load. A hard reload of the URL you are already on is the one case the router refuses, so it throws.
Why middleware triggered it on the 404 page#
In 13.1.0, client/index.tsx replaces the router state on mount when the page was statically exported and "middleware matches the current page (may have rewrite params)". A custom pages/404.js is statically exported, and a middleware without a matcher matches every path, so the 404 page ran a client-side router.replace of its own URL as soon as it hydrated. That refresh fetched the data for /page-does-not-exist, tried to load a page chunk that does not exist, and landed in handleRouteInfoError, which asked for a hard navigation to the same URL.
The fix in PR #46303 ("Fix error on 404 page when middleware exists", merged 23 February 2023) records whether the router is sitting on /404 or /_error before the route is resolved, and passes the query-update flag through when it re-fetches the error route, so the hydration refresh stays a query update. A comparison of the merge commit against the release tags (v13.2.0 against the merge commit) shows 13.1.6 does not contain it and 13.2.0 does.
The reproduction used a custom pages/404.js and also ran a negative control: removing middleware.js made the error go away, with the custom 404 page still present and the dev server restarted. The middleware is what turned the 404 page into a page that refreshes itself on mount.
Fix#
1. On Next.js 13.1.x (reported from 13.0.7): upgrade#
The first stable release with the fix is 13.2.0 (there is no stable 13.1.7). Do not stop there, though: an app that does authorization in middleware on any 13.x release before 13.5.9 is exposed to CVE-2025-29927, the middleware authorization bypass, and 13.5.9 is the first patched 13.x. If you have to stay on 13.x, upgrade straight to it:
npm install next@13.5.913.5.9 contains the PR #46303 commit as well, and so does every later release, including the current 16.x line. The reproduction tested the smallest step, changing only the version from 13.1.0 to 13.2.0: on the same app, with the same middleware and custom 404 page, the 404 page renders without the invariant:
--- GET http://localhost:32768/page-does-not-exist
[navigated] http://localhost:32768/page-does-not-exist
[data] 200 /_next/data/development/page-does-not-exist.json
--- final: http://localhost:32768/page-does-not-exist | Custom 404 | overlay shows 'Unhandled Runtime Error': falseDo not spend time on a matcher that excludes /404: one commenter on the issue reported that adding the 404 page to the matcher exclusions did not help. On Next.js 16 the file is called proxy.js (the proxy.js reference lists the rename under v16.0.0), and the proxy.js matcher guide covers how to keep it off static assets once you have upgraded.
2. On current versions: catch the same-URL navigation and reload#
On Next.js 16.4.3 the NEXT-435 path is gone. With a proxy.js that only returns NextResponse.next() and the same custom 404 page, the dev server answers the data request with a 404 and the page renders cleanly:
--- GET http://localhost:32769/page-does-not-exist
[navigated] http://localhost:32769/page-does-not-exist
[data] 404 /_next/data/development/page-does-not-exist.json
--- final: http://localhost:32769/page-does-not-exist | Custom 404 | overlay shows 'Unhandled Runtime Error': falseThe invariant itself is still there, though, and any navigation to the current URL whose data request fails reaches it. The pattern seen in the wild is router.push(window.location.href) used to re-run getServerSideProps or middleware on the current page; issue #80124 shows an auth library doing exactly that, and it is still open. The common real-world failure is a tab that stayed open across a deployment: its /_next/data/<buildId>/ URL no longer exists on the new build.
That scenario, on a production build (next build then next start), with the page left open while the app was rebuilt and restarted, and no proxy file at all:
--- page left open; running: docker exec aotd-next-435-v1643 sh /prod.sh
buildId: cmA2WOW57Od19oKXzAlis
next start running
--- click #same
[data] 404 /_next/data/Ta-L5W690Cj2gqXNzoRiE/index.json
[pageerror] Error: Invariant: attempted to hard navigate to the same URL / http://localhost:32769/router.push returns a promise, and that promise is what rejects with the invariant. Nothing in the app was handling it, which is why it surfaced as an unhandled rejection. Handle it where you navigate:
import { useRouter } from 'next/router'
export function useRefreshCurrentPage() {
const router = useRouter()
return () =>
router.push(window.location.href).catch((err) => {
if (String(err?.message).startsWith('Invariant: attempted to hard navigate to the same URL')) {
// The router could not load this URL's data and will not reload it itself.
console.warn('stale client, reloading:', err.message)
window.location.reload()
return
}
throw err
})
}The router has already decided a full load is needed; this does that load. Any other error keeps propagating, so a real bug is not hidden behind a reload.
Verify the fix#
Repeat the failing sequence with the guarded call: open the page on one build, rebuild and restart the server, then trigger the same-URL navigation. On 16.4.3 the data request still 404s, but the rejection is caught and the browser performs a real navigation to the fresh build:
--- click #safe
[data] 404 /_next/data/KMo84C-FZ4STpSb5VDmlN/index.json
[console.warn] stale client, reloading: Invariant: attempted to hard navigate to the same URL / http://localhost:32769/
[navigated] http://localhost:32769/You can also check the condition without a redeploy. In Chrome DevTools, open the Network tab, right-click a /_next/data/ request and choose "Block request URL", then trigger the same-URL navigation. The reproduction used the equivalent DevTools protocol call and got the invariant on 16.4.3 in next dev with and without a proxy file, so middleware is not part of the modern trigger.
As a control, the same blocked data request on a navigation to a different page (router.push('/other')) did not throw: the browser simply loaded /other in full. Only the same-URL case is refused.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
deploymentId does not cover the same-URL case#
The deploymentId documentation says that when the client detects a mismatch between its deployment ID and the server's, "it triggers a hard navigation (full page reload) instead of a client-side navigation". For a navigation to another page that is what you want, and it is the right setting against ChunkLoadError after a deploy.
For a navigation to the current URL, a hard navigation is exactly what handleHardNavigation refuses. In the Pages Router source, a data response whose deployment header differs from the client's raises "Loaded static props were from an outdated deployment, forcing a hard reload", marked as an asset error, and that error takes the same handleRouteInfoError path as a failed request. Built once with NEXT_DEPLOYMENT_ID=dep-1, left open, rebuilt with NEXT_DEPLOYMENT_ID=dep-2, the 16.4.3 app kept a constant build ID (the data request succeeded with a 200), and threw anyway:
--- click #same
[data] 200 /_next/data/build-TfctsWXpff2fKS/index.json
[pageerror] Error: Invariant: attempted to hard navigate to the same URL / http://localhost:32769/The guarded call from the fix above handles this case too. Same two builds, same open tab, #safe instead of #same:
--- click #safe
[data] 200 /_next/data/build-TfctsWXpff2fKS/index.json
[console.warn] stale client, reloading: Invariant: attempted to hard navigate to the same URL / http://localhost:32769/
[navigated] http://localhost:32769/Keep deploymentId for its intended job, and keep the .catch for same-URL refreshes. If the page you are refreshing is protected by middleware that does not seem to run at all in production, that is a different problem, covered in middleware not running on Vercel.
Reading the stack trace#
In next dev on 16.4.3 the frames name the path directly, which tells you which of the two cases you have:
[pageerror] Error: Invariant: attempted to hard navigate to the same URL / http://localhost:32769/
at handleHardNavigation (http://localhost:32769/_next/static/chunks/node_modules_next_dist_shared_lib_196_nl_l493zz._.js:2160:15)
at Router.handleRouteInfoError (http://localhost:32769/_next/static/chunks/node_modules_next_dist_shared_lib_196_nl_l493zz._.js:2948:13)
at Router.getRouteInfo (http://localhost:32769/_next/static/chunks/node_modules_next_dist_shared_lib_196_nl_l493zz._.js:3182:25)
at async Router.change (http://localhost:32769/_next/static/chunks/node_modules_next_dist_shared_lib_196_nl_l493zz._.js:2701:29)handleRouteInfoError means the page or its data could not be loaded, so open the Network tab and look for the failed /_next/data/ or chunk request just before the error. The router emits routeChangeError with the original error right before it calls handleHardNavigation, which is the other place to log the real cause. If you are on 13.1.x, the console line just before the invariant was Failed to load script: /_next/static/chunks/pages/... for a URL that has no page: that is NEXT-435, and the upgrade is the whole fix. If the message names your current page and you are on a recent release, it is a failed same-URL refresh, and the guarded router.push above handles it. If a refresh after a deploy fails with ChunkLoadError instead of this invariant, the cause is the stale chunk itself rather than the router's refusal to reload, and the ChunkLoadError page linked above is the next stop.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: useRouter query undefined on first render
Why Next.js hydrates a dynamic Pages Router page with an empty query object, how router.isReady fixes it, and why the App Router does not have this exact problem.
Fix: Next.js proxy.js matcher not working for static assets
Without a matcher, proxy.js runs on every request including _next/static and public assets. And Server Functions are not separate routes — a matcher gap silently drops auth coverage.
Next.js 'URL is malformed' error: use only absolute URLs
Reproduced on Next.js 16.4.2: the relative redirect that throws, the absolute redirect that then loops on itself, and the basePath case where new URL quietly drops the prefix.


