JavaScript Redirects Are Leaking PageRank to Nowhere
On this page
- Why the signal location matters more than rendering
- Serverless still emits HTTP responses
- Verify at the protocol level
- Pick the right permanent redirect
- Repoint internal links and mind the chain
- Framework configs versus runtime navigation
- Why waiting does nothing
- Frequently Asked Questions
- Sources
- Related posts:
If you moved a page months ago and the old URL still ranks on its own, sometimes competing with the page you redirected it to, the cause is almost never “Google needs more time.” It is that Google never registered a redirect at all. A client-side redirect (a router.push, a window.location assignment, a useEffect that navigates) lives in the scripted layer, not in the HTTP response. The redirect signal that consolidates link equity is an HTTP-level signal, so when the server answers the old URL with 200 OK and the move happens only after the JavaScript boots, Google sees a live page that happens to send users elsewhere, not a redirect. Equity stays attributed to the old URL and consolidation never happens.
Why the signal location matters more than rendering
The reflex is to blame rendering: “Google can’t see my JavaScript redirect.” That framing is wrong and it sends people down the wrong fix. Googlebot does render JavaScript. The problem is not visibility, it is the channel the instruction arrives on.
A redirect is a relationship between two URLs that Google records from the response status line: a 301 or 308 with a Location header is an unambiguous, machine-level statement that URL A has become URL B. That statement is processed at the protocol layer, before and independent of any page content. A window.location.href = '/new' carries no such status. The server already committed to 200 OK for the old URL; the navigation that follows is a user-experience behavior, the same category as a button click. Google’s own guidance treats server-side redirects as the clearest signal and follows JavaScript redirects only on a best-effort basis when it renders the page. Best-effort is not “ignored,” but it is also not the durable, equity-consolidating relationship a real redirect creates. The dependable path is to make the redirect part of the response, not part of the script.
Serverless still emits HTTP responses
The common objection is “I am on Vercel or Netlify, there is no server to write a redirect on.” There is. Serverless and static hosts still answer every request with an HTTP response, and they all expose an edge configuration layer that runs before your application code or your single-page app ever boots. That is exactly where a redirect belongs.
- Vercel: a
redirectsarray invercel.jsonwithsource,destination, andpermanent: true(which emits a308). Vercel defaults to307/308rather than301/302, and these redirects are evaluated at the edge across regions before the app renders. - Netlify: a
_redirectsfile or a[[redirects]]block innetlify.toml, with an explicit301!(or308) status. The!forces the redirect even if a file exists at the path. - Cloudflare Pages: a
_redirectsfile with the target status code, processed at the edge.
The point that ties these together: the edge redirect executes before the SPA loads, so Googlebot receives the status code on the first response and never has to render anything to learn about the move. Verify the current syntax in each platform’s docs before shipping, because the defaults and accepted status codes differ between them.
Verify at the protocol level
Do not trust the browser address bar; it shows you the post-navigation state, which is exactly the thing that misled you. Test the response itself.
curl -I https://example.com/old-url
You want to see HTTP/2 301 (or 308) and a Location: header pointing at the new URL. If you see HTTP/2 200, the SPA is still answering the old URL as a live page and your “redirect” is client-side only. That single line is the whole diagnosis. After you ship the edge redirect, re-run curl -I to confirm the status, then use URL Inspection in Search Console to confirm Google follows it.
Pick the right permanent redirect
For a permanent move, use a permanent redirect. Both 301 and 308 are permanent; 308 additionally preserves the request method and body, which matters for non-GET requests but is irrelevant for an ordinary page move, so either is correct. The temporary codes 302 and 307 are the wrong tool for a permanent move: they tell Google to keep the old URL in the index because the change is expected to be undone. Frame the choice as method semantics and permanence, not as a “302 loses equity” claim. The failure you are fixing is not “I used the wrong permanent code,” it is “I used no HTTP-level redirect at all.”
Repoint internal links and mind the chain
Once the redirect is live, stop relying on it for your own navigation. Update internal links to point at the final destination URL directly. Two reasons. First, every internal link that still points at the old URL forces a redirect hop on each click, which adds latency and an unnecessary round trip. Second, if those legacy links accumulate into a chain (old URL to interim URL to final URL), each hop is a step Google has to follow and a small amount of friction in consolidation. Treat redirect chains as latency and an avoidable hop to clean up, not as a quantified equity tax; there is no reliable public figure for how much a hop “costs,” and inventing one would be fabrication. The practical rule is one hop, then fix the sources.
Framework configs versus runtime navigation
A subtlety trips up teams on modern frameworks: the framework may offer both an HTTP-level redirect mechanism and a runtime navigation API, and they are not interchangeable for this purpose. In Next.js, a redirects() entry in next.config.js is compiled into an HTTP redirect served before the page renders, which is the correct, equity-consolidating tool. By contrast, calling redirect() or router.push() inside a component, or navigating in a useEffect, happens in the application layer and is the failure mode this post is about. The two look similar in a code review and behave identically to a clicking user, but only the config-level redirect emits a status code Google records as a redirect relationship.
The practical rule: if a move is permanent, it belongs in the platform or framework config that produces an HTTP response, not in component logic. Reserve router.push and window.location for genuine in-app navigation (a user completing an action, a post-login bounce), never for a permanent URL change you want search engines to honor. When you audit a migration, grep the codebase for runtime navigation calls that are standing in for redirects, and move each one to the edge or framework config.
Why waiting does nothing
The diagnostic tell that exposes the whole problem: the old URLs keep ranking independently, and sometimes outrank or cannibalize the new page, long after a merge that should have consolidated them. A generic answer says “recrawl and consolidation take time, be patient.” Patience is the wrong prescription here because there is no redirect for Google to honor. Google is treating two live, separately-indexable URLs as two pages, which is precisely what your server told it. Recrawl and consolidation do have real lag once a true redirect exists, and that lag is variable and crawl-budget dependent, so do not promise a date. But the lag clock only starts after Google actually sees a 301/308. Until you move the redirect to the HTTP layer, more waiting buys you nothing.
Frequently Asked Questions
Does Google ever honor a JavaScript redirect?
It can, on a best-effort basis, once it renders the page. The issue is reliability and consolidation strength, not absolute blindness. An HTTP-level redirect is processed before rendering and is the dependable way to transfer signals, so use it for any deliberate, permanent move.
Is a meta refresh a safe alternative?
A meta refresh is still a client-layer instruction and is a weaker signal than an HTTP redirect. Google can follow a near-instant meta refresh, but it is a fallback, not the recommended mechanism. If you can emit a status code at the edge, do that instead.
Sources
- Google Search Central, “Redirects and Google Search”: https://developers.google.com/search/docs/crawling-indexing/301-redirects
- Vercel Documentation, “Redirects”: https://vercel.com/docs/routing/redirects
- Netlify Documentation, “Redirects and rewrites”: https://docs.netlify.com/routing/redirects/