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.
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 plainRedirect 301 /blog /newsdoes not (it would send/bloggerto/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.