Redirects on a static site: moving pages without breaking links

Moved a page, renamed a folder or changed domain? A permanent redirect from every old address to its new one keeps links and search working. Where you write it depends on the host — and the same line means 301 on one host and 302 on another, and is ignored on a third if the old file is still there.

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.

TEXT
/about.html      /about        301
/old-pricing     /pricing      301

A whole folder. * matches the rest of the path and :splat puts it back:

TEXT
/blog/*          /posts/:splat 301

On 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:

TEXT
/*   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:

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,

YAML
---
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:

NGINX
location = /about.html { return 301 /about; }
APACHE
Redirect 301 /about.html /about

Write 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:

BASH
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:

TEXT
/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

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.

This site is the product it is written about.

Drag a folder in and read the link back. No account and no card to publish.