You renamed /about.html to /about, moved /blog/ to /posts/, or put the whole site on a new domain. Every link anybody ever shared to the old addresses now ends in a 404, and so does every search result that still points at them.
The fix is a permanent redirect — a 301 or 308 — from every old address to its new one. The server answers the old URL with a status code and a Location header, and browsers and search engines follow it. A static host has no code of yours to run, so you write the rule in a file or a setting the host reads. Two details catch people out: the same rule line defaults to a permanent redirect on some hosts and a temporary one on others, and on some hosts a file still sitting at the old path quietly wins over the rule.
The 30-second version
| Host | Where the rules live | Status if you don't write one | A file still at the old path… |
|---|---|---|---|
| Netlify | _redirects in the publish folder, or netlify.toml |
301 | wins, unless the rule ends in ! |
| Cloudflare Pages | _redirects in the build output |
302 | loses — the rule always runs |
| Vercel | redirects in vercel.json |
308 (permanent: true) |
not stated in Vercel's docs |
| GitHub Pages | no server rules; redirect_from in a page's front matter |
an HTML page with a meta refresh | — the redirect is a file |
| nginx | return 301 …; in the server config |
302 if you write only a URL (return https://…;) |
your config decides |
| Apache | Redirect / RedirectMatch |
302 | your config decides |
| dropto.run | _redirects at the top of the folder |
301 | wins, unless the rule ends in ! |
What a redirect is
A redirect is one HTTP response: a status code in the 300s and a Location header naming where to go. The status says how long the move lasts.
- Permanent: 301 or 308. Search engines treat the new address as the one to show; Google uses a permanent redirect as a signal that the redirect target should be canonical. Browsers may remember it.
- Temporary: 302, 303 or 307. The old address stays the real one. Google does not move its signals.
The difference between 301 and 308 is the request method. For a 301 or 302, RFC 9110 notes that for historical reasons, a user agent MAY change the request method from POST to GET — so a form posted to a redirected address can arrive as a plain GET. A 307 or 308 keeps the method. For pages people read, 301 and 308 behave the same; for a form or an API endpoint, use 308 where the host offers it. Vercel, Cloudflare Pages, nginx and Apache do; Netlify's _redirects documents only 301 and 302.
A rewrite is a different thing: the host serves another file while the address bar stays the same, usually written with status 200. The rule that sends every unknown path to index.html for a single-page app is a rewrite, and why a static site 404s on refresh covers it. This article is about addresses that have actually moved.
The four moves
Written in _redirects syntax — from to status, one rule per line, first match wins — which Netlify, Cloudflare Pages and some other hosts read. The Vercel equivalents follow in the next section.
One page.
/about.html /about 301
/old-pricing /pricing 301A whole folder. * matches the rest of the path and :splat puts it back:
/blog/* /posts/:splat 301On our test site (on the host in the last section below), /blog/hello and /blog/a/b came back as 301s to /posts/hello and /posts/a/b. A named segment works the same way for one part of the path: /products/:id /items/:id 301.
Old .html addresses. If you moved to extension-less URLs, redirect the old ones explicitly, one by one or with a pattern your host supports. Some hosts do this for you: Vercel's cleanUrls option redirects .html paths, and Cloudflare Pages redirects /contact.html to /contact on its own (its docs don't state the status). Check before writing rules that fight the host.
A whole domain. Point every path on the old domain at the same path on the new one. On a site that serves only the old domain, one rule does it:
/* https://new.example.com/:splat 301!Be careful where that rule lives. If the same site also answers on the new domain, a bare /* rule sends the new domain to itself, forever. Netlify's answer is to key the rule to the old host — https://old.example.com/* https://new.example.com/:splat 301!. Cloudflare Pages lists domain-level redirects as unsupported in _redirects and does not document !, so there keep the old domain on a site of its own and write the rule without !.
Then tell Google, and keep the old domain answering: Google suggests keeping redirects for as long as possible, generally at least 1 year. Pointing a custom domain at a static site covers getting the new domain itself working.
Host by host
Netlify
A file called _redirects in the folder you publish. If you leave the status off, the default is 301. A path matches with or without a trailing slash. 307 is listed as unsupported — Netlify says to use 302 instead.
The trap: by default, you can't shadow a URL that actually exists within the site. If about.html is still in your output, /about.html /about 301 does nothing. Delete the old file, or end the rule with ! to force it.
Cloudflare Pages
The same _redirects format, in the build output folder. It supports 301, 302, 303, 307 and 308, and 302 is used as the default status code — so a line copied from a Netlify site without a status becomes a temporary redirect here. Write the 301.
The opposite trap from Netlify: on Cloudflare Pages, redirects are always followed, regardless of whether a file matches the request. A file left at the old path does not stop the rule. A file holds up to 2,000 static and 100 dynamic rules.
Vercel
Rules go in vercel.json:
{
"redirects": [
{ "source": "/about.html", "destination": "/about", "permanent": true },
{ "source": "/blog/:path*", "destination": "/posts/:path*", "permanent": true }
]
}permanent: true is the default and sends a 308; false sends a 307. If you need exactly 301 or 302, use statusCode instead. Redirects are processed before rewrites.
GitHub Pages
GitHub Pages has no server-side redirect rules. The supported way is the jekyll-redirect-from plugin, which is not on by default: add it to your _config.yml under plugins:, then list the old addresses in a page's front matter,
---
redirect_from:
- /old-about/
- /about.html
---and the build writes a small page at each old address. The plugin is explicit about what that page is: redirects are performed by serving an HTML file with an HTTP-REFRESH meta tag. The status is 200, not 301. That is still usable — Google says it interprets instant meta refresh redirects as permanent — but Google also recommends a server-side redirect whenever you can have one, and other crawlers and tools may not follow a meta refresh at all. The plugin only runs when GitHub builds the site with Jekyll; for a site deployed some other way, you write that HTML page yourself.
nginx and Apache
On a server you control, the redirect is one line of config:
location = /about.html { return 301 /about; }Redirect 301 /about.html /aboutWrite the status on both. On Apache, without one, the redirect will be "temporary" (HTTP status 302); on nginx, return with only a URL is a temporary redirect with the code 302.
dropto.run
The same _redirects file at the top of the folder you publish (documented here), with Netlify's semantics: 301 when you don't write a status, a file at the path wins unless the rule ends in !, and a trailing slash makes no difference. It also takes 302, 303, 307, 308, 200 for a rewrite and 404 for a custom not-found page, up to 1,000 rules; a line it cannot use is skipped rather than failing the deploy.
Check it
You do not need a browser — and a browser that has cached an old redirect will mislead you. Ask for the old address without following the redirect:
curl -sI https://example.com/old-page | grep -iE '^(HTTP|location)'You want a 301 or 308 and the new address. Here is what our test site — on the host in the last section above — answered for a few rules, on 8 November 2026:
/old-page 301 /new-page
/old-page?utm=x 301 /new-page?utm=x
/blog/a/b 301 /posts/a/b
/exists.html 200 (the file is still there, so the rule is skipped)
/forced.html 301 /new-page (the same situation, with !)The query string came along to the new address. The fourth line is the same shadowing behaviour Netlify documents, measured on a host that follows Netlify's rules.
Troubleshooting
The rule does nothing. On Netlify and similar hosts, a file still answers that path. Delete the old file from the output, or force the rule with !. Also check the order: the first matching rule wins, so a broad rule higher up can catch the path first.
The rule file is in the repository but not on the site. _redirects has to be in the folder the host serves — usually the build output, so put it in public/ (Vite) or static/ and let the build copy it.
You get a 302 instead of a 301. You did not write a status, and the host defaults to temporary — Cloudflare Pages and Apache both do. Write 301 explicitly.
The browser keeps going to the old destination after you fixed the rule. RFC 9110 calls a 301 and a 308 heuristically cacheable: unless the response says otherwise, a browser may keep it and not ask again. Test with curl, or in a private window. Some hosts send redirects with Cache-Control: max-age=0 so browsers check again every time; the host we tested sent public, max-age=0, must-revalidate on every redirect.
A form arrives as a GET. It was posted to an address that answers 301 or 302. Use 308 for anything that receives a POST, on a host that offers it.
Search Console says "Page with redirect". That is the old address, correctly redirected — it will not be indexed, and the new one may be. Why your site isn't showing up on Google covers what to check on the new address.
When not to do this
- Don't send every deleted page to the home page. Google warns against redirecting many old URLs to one irrelevant single URL destination, such as the home page; it can be treated as a soft 404. If a page is gone with no replacement, let it return a 404.
- Don't ship a 301 while you are still deciding. Browsers may hold on to a permanent redirect. Use 302 until the new addresses are settled, then switch.
- Don't use a redirect to fix a single-page app that 404s on refresh. That needs a rewrite, not a redirect.
- Don't rely on a JavaScript redirect if you can avoid it. Google says to only use JavaScript redirects if you can't do server-side or meta refresh redirects.
Questions
301 or 308 — does it matter?
Both are permanent, and Google treats both as a move. The difference is that a 301 lets a browser turn a POST into a GET, and a 308 does not. For pages people read, either works; for forms and APIs, use 308 where the host supports it — Netlify's _redirects documents only 301 and 302.
How long should I keep redirects?
Google suggests at least a year after a site move, and as long as possible. In practice, keep them as long as old links to the addresses exist anywhere.
Can GitHub Pages do a real 301?
No. The jekyll-redirect-from plugin writes an HTML page with a meta refresh at the old address. Google treats an instant meta refresh as permanent, but it is still a 200 with a page, not an HTTP redirect.
Why does my redirect do nothing?
Most often a file still exists at the old path, and the host serves the file instead of applying the rule. Delete it or add ! on hosts that support it. On Cloudflare Pages that is never the reason, because rules always run there.
Does the query string survive a redirect?
On Netlify, yes, for 200, 301 and 302 rules. On the host we tested, /old-page?utm=x went to /new-page?utm=x, and a target with its own query string kept that one instead. Cloudflare Pages does not document it, so check with curl.
I didn't write a status. Is my redirect permanent?
It depends on the host: Netlify defaults to 301; Cloudflare Pages, Apache and nginx's return with only a URL to 302; Vercel to 308. Always write the status.
How do I move a whole site to a new domain?
One splat rule that sends every path to the same path on the new domain with a 301, kept for at least a year — on a site that serves only the old domain, or keyed to the old host on Netlify, so the new domain is not redirected to itself. Make sure each old page lands on its own new page, not on the new home page.