301 Redirects in .htaccess: the Correct Forms

Redirect vs RedirectMatch vs RewriteRule for 301s — the prefix-match trap, chains, query strings, browser caching, and how to verify with curl.

Published 2026-10-04

A 301 tells the client — and every search engine — “this URL moved forever, update your records.” Done right it is invisible to users and lossless for rankings. Done wrong it leaks: chains that drop link equity, prefix matches that steal unrelated URLs, loops that take the site down with ERR_TOO_MANY_REDIRECTS.

Three directives that all produce a 301

Redirect (mod_alias) — the simple one, with a catch:

Redirect 301 /old-page /new-page

The catch: Redirect matches a path prefix, not an exact path. /old-page also matches /old-page/anything (redirecting to /new-page/anything, which is often exactly what you want for a moved section) and /old-pageX — because the match is a raw string prefix, not a path-segment match. The carryover of subpaths is documented behavior; the /old-pageX over-match is the part nobody warns you about.

RedirectMatch (mod_alias) — same module, regex instead of prefix:

RedirectMatch 301 ^/old-page\.html$ /new-page.html

Anchored and escaped, this matches exactly /old-page.html. For “this one URL moved,” this is the precise tool — it’s what the generator’s exact page rows emit.

RewriteRule with [R=301] (mod_rewrite) — when the redirect needs conditions:

RewriteCond %{QUERY_STRING} id=42
RewriteRule ^product\.php$ /products/widget [R=301,L]

mod_alias can’t see query strings, hosts or schemes. The moment a redirect needs “only for HTTP”, “only on this domain” or “only when this parameter exists,” you’re in mod_rewrite territory.

The whole-section move

For “everything under /blog/ now lives under /news/”, the clean form is:

RedirectMatch 301 ^/blog(/.*)?$ /news$1
  • /blog → /news
  • /blog/post-title → /news/post-title
  • /blogger → does not match — the (/.*)? group anchors the boundary, which plain Redirect 301 /blog /news does not (it would send /blogger to /newser).

The bare /blog → /news case is why (/.*)? is grouped and optional — ^/blog/(.*)$ alone leaves the section root behind.

Chains, loops and cached 301s

One hop is the budget. /a → /b → /c costs an extra round-trip for users and bleeds PageRank at every step — and Google may stop following after a few hops. When you add a rule, point old URLs at the final destination, not at another redirect. The canonical-host/HTTPS rule the generator emits merges two hops (http + non-www) into one for exactly this reason.

Loops happen when a rule’s output still matches its own input — redirecting /a to a path that maps back to /a, or forcing HTTPS in a setup where Apache can never see HTTPS (a proxy that terminates TLS; the fix is %{HTTP:X-Forwarded-Proto}, covered in forcing HTTPS).

301s are cached forever by browsers. There is no expiry in the spec, and Chrome/Firefox treat them as effectively permanent — a wrong 301 can follow your visitors around for weeks. Always test with curl -I (which has no cache) before clicking through a warm browser, and get the rule right on staging before it ships.

Query strings: the silent drop

RewriteRule ^old$ /new?ref=nav [R=301,L]
  • A substitution containing ? replaces the original query entirely.
  • RewriteRule ^old$ /new [R=301,L,QSA] appends the original query after any new parameters.
  • RewriteRule ^old$ /new? [R=301,L] — the bare ? — explicitly deletes the query string, the standard trick for stripping tracking params.

mod_alias Redirect/RedirectMatch always pass the original query string through — you can’t trim it there.

Verify before you trust it

curl -I http://example.com/old-page.html
# want: HTTP/1.1 301 Moved Permanently + Location: https://example.com/new-page.html

Check all four scheme/host variants (http/https × apex/www) for every rule — a 301 that only fires on one variant is a duplicate-content leak. Then check the destination returns 200, not another 3xx.

For the surrounding context — where these rules sit relative to other rewrites — see how mod_rewrite works; for the canonicalization side, force HTTPS + one host.