Short answer: to redirect with .htaccess, write Redirect 301 /old-page https://example.com/new-page for a simple move. For anything conditional, such as HTTP to HTTPS or www to non-www, turn on mod_rewrite and pair RewriteCond with a RewriteRule that ends in [R=301,L]. Apache only reads these lines where AllowOverride permits FileInfo.
# a single moved page (mod_alias)
Redirect 301 /old-page https://example.com/new-page
# every HTTP request to HTTPS (mod_rewrite)
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]Every directive and flag on this page follows the Apache HTTP Server 2.4 documentation for mod_alias, mod_rewrite, the RewriteRule flags and .htaccess files, read on 21 October 2026. There is no Apache on the machine this was written on, so the rules were not run here: test each one with the curl commands further down before you rely on it.
.htaccess redirect cheat sheet
| Task | Lines in .htaccess |
|---|---|
| One page | Redirect 301 /old-page https://example.com/new-page |
| A folder, keeping the rest of the path | Redirect 301 /old-folder https://example.com/new-folder |
| Whole site to a new domain, paths kept | Redirect 301 / https://new-domain.com/ |
| HTTP to HTTPS | RewriteCond %{HTTPS} offRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] |
| www to non-www | RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L] |
| Non-www to www | RewriteCond %{HTTP_HOST} ^example\.com$ [NC]RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L] |
| Drop the query string | RewriteRule ^old\.php$ https://example.com/new/ [R=301,L,QSD] |
| Page removed for good (410) | Redirect gone /old-page |
The mod_rewrite lines need RewriteEngine On once above them. Each recipe is explained below, with the traps that come with it.
Redirect and RedirectMatch (mod_alias)
Redirect [status] URL-path URL matches a path prefix and appends whatever follows it. So Redirect 301 /service https://example.com/products also sends /service/foo.txt?q=23 to https://example.com/products/foo.txt?q=23. Only complete path segments match: /servicefoo.txt is left alone. The old path must start with a slash, the target is a full URL or a path on the same host, and the query string comes along by itself.
- No status means 302. Write
301orpermanentfor a permanent move;tempis 302,seeother303,gone410 (with no target URL). - POST bodies are discarded, as the mod_alias docs note. Do not redirect a form or API endpoint this way; point the client at the new address.
- One page means a prefix too.
Redirect 301 /old-page ...also catches/old-page/anything. For exactly one URL, use RedirectMatch with anchors.
RedirectMatch does the same with a regular expression against the URL path, leading slash included, and fills $1 and up from the groups:
# exactly one URL, with or without a trailing slash
RedirectMatch 301 ^/old-page/?$ https://example.com/new-page
# /blog/2024/some-post -> /articles/some-post
RedirectMatch 301 ^/blog/[0-9]{4}/(.+)$ https://example.com/articles/$1Anchor the pattern with ^ and $: an unanchored RedirectMatch 301 /old https://... also fires for /shop/old-stock.
mod_rewrite: RewriteEngine, RewriteCond and RewriteRule
mod_rewrite is for redirects that depend on something other than the path: the hostname, HTTPS, the query string, whether a file exists. A rule set looks like this:
RewriteEngine On
RewriteCond TestString CondPattern [flags] # zero or more conditions, ANDed
RewriteRule Pattern Substitution [flags] # applies only if all conditions matchFour rules from the documentation explain most broken .htaccess redirects:
- In .htaccess the pattern never starts with a slash. Apache strips the directory prefix before matching, so
^/old-page$never matches there; write^old-page$. In aVirtualHostthe slash is part of the path. - The query string is not part of the pattern. Match it with
RewriteCond %{QUERY_STRING}, and the hostname with%{HTTP_HOST}. - Conditions are ANDed. Add
[OR]to a condition to combine it with the next one instead, and[NC]to ignore case. $1comes from the RewriteRule,%1from the last matched RewriteCond. The www recipe below depends on that difference.
| Flag | Effect |
|---|---|
| R=301 | External redirect with that status; a bare R sends 302 |
| L | Stop processing this pass of the rule set; use it with every R |
| END | Stop all further rewriting, also on the next pass in .htaccess |
| NC | Case-insensitive match |
| QSD | Drop the incoming query string (Apache 2.4.0 and later) |
| QSA | Append the incoming query string to one you set in the target |
| NE | Do not percent-encode the target, so a # anchor stays a # |
The docs explain why R wants L: on its own it rewrites the URL and hands it to the next rule, which leads to odd follow-up rewrites. mod_rewrite in .htaccess also needs Options FollowSymLinks or SymLinksIfOwnerMatch in effect, which Debian’s and Ubuntu’s default Apache config already sets for /var/www.
Recipes
HTTP to HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]^ matches every path, %{REQUEST_URI} carries the path across, and the query string is appended on its own. The site needs a working certificate first, or visitors land on a browser warning. If the server sits behind Cloudflare, a load balancer or an nginx that ends TLS, %{HTTPS} is always off and this rule loops; the section on proxies and CDNs below has the fix.
www to non-www, and back
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]The first rule reuses whatever followed www. through %1, so it works for any domain on the account. The second names the bare domain on purpose: the popular variant RewriteCond %{HTTP_HOST} !^www\. also catches subdomains and sends shop.example.com to www.shop.example.com. To do HTTPS and the hostname in one hop instead of two, combine the conditions:
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]Why one hop matters for search engines, and how chains build up, is covered in 301 Moved Permanently.
A whole folder
# keep the rest of the path: /old-folder/a/b -> /new-folder/a/b
Redirect 301 /old-folder https://example.com/new-folder
# the same with mod_rewrite, if the file already uses it
RewriteRule ^old-folder(/.*)?$ https://example.com/new-folder$1 [R=301,L]Sending every page of a folder to one single page is possible (RedirectMatch 301 ^/old-folder(/.*)?$ https://example.com/new-page), but search engines may treat mass redirects to an unrelated page as soft 404s. Map old pages to their real successors where they exist.
A domain move that keeps every path
Redirect 301 / https://new-domain.com/Because Redirect appends the rest of the path and the query string, old-domain.com/blog/post?x=1 lands on new-domain.com/blog/post?x=1. When both domains point at the same document root, limit the rule to the old name:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?old-domain\.com$ [NC]
RewriteRule ^ https://new-domain.com%{REQUEST_URI} [R=301,L]Keep a valid certificate on the old domain for as long as the redirect runs: an HTTPS visitor meets the certificate before Apache can send the 301.
Trailing slash
RewriteEngine On
# remove it: /page/ -> /page (real directories excluded)
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ https://example.com/$1 [R=301,L]
# or add it: /page -> /page/ (files and paths with an extension excluded)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !(\.[a-zA-Z0-9]{1,5}|/)$
RewriteRule ^ https://example.com%{REQUEST_URI}/ [R=301,L]Use one of the two blocks, never both. Real directories get their slash from mod_dir’s DirectorySlash anyway, which redirects /docs to /docs/ with a 301. On WordPress, leave the slash to the permalink setting: WordPress redirects every URL to its own canonical form, and a rule that disagrees with it loops.
Query strings
RewriteEngine On
# drop the query string entirely
RewriteRule ^old\.php$ https://example.com/new/ [R=301,L,QSD]
# /product.php?id=42 -> /products/42 (and nothing for other parameters)
RewriteCond %{QUERY_STRING} (?:^|&)id=([0-9]+)(?:&|$)
RewriteRule ^product\.php$ https://example.com/products/%1 [R=301,L,QSD]
# a target with an anchor: NE keeps the # from becoming %23
RewriteRule ^faq-shipping$ https://example.com/faq#shipping [R=301,L,NE]Without QSD the old query string is copied onto the target, which is how /products/42?id=42 happens. Before QSD arrived in Apache 2.4.0, the same was done with a lone ? at the end of the target, which still works and still shows up in older guides.
301 vs 302 vs 307 vs 308 in .htaccess
| Code | Meaning | mod_alias | mod_rewrite |
|---|---|---|---|
| 301 | Moved permanently; POST may become GET | Redirect 301 | [R=301,L] |
| 302 | Temporary; the default of both modules | Redirect | [R,L] |
| 307 | Temporary, method and body kept | Redirect 307 | [R=307,L] |
| 308 | Permanent, method and body kept | Redirect 308 | [R=308,L] |
| 410 | Gone, no target | Redirect gone | [G] |
Browsers cache 301 and 308 answers, often for a long time, so a wrong permanent rule keeps misdirecting people after you fix it. Write new rules with 302 ([R=302,L]), check them, then switch to 301. The full comparison, including what each code does to a POST, is in the 301 guide’s status code table.
Why your .htaccess redirect does nothing
Apache’s default for AllowOverride is None, and Debian and Ubuntu ship AllowOverride None for /var/www. With that, Apache never opens .htaccess and your redirect silently does nothing. Redirect, RedirectMatch and all rewrite directives need the FileInfo category:
<VirtualHost *:443>
ServerName example.com
DocumentRoot /var/www/example.com
<Directory /var/www/example.com>
AllowOverride FileInfo
Options FollowSymLinks
Require all granted
</Directory>
# ... SSLEngine and certificate lines ...
</VirtualHost>sudo a2enmod rewrite
sudo apachectl configtest
sudo systemctl restart apache2AllowOverride All works too but hands .htaccess more power than redirects need. When the file is read but a directive is not allowed or its module is missing, you get a 500 instead, with not allowed here or Invalid command 'RewriteEngine' in the error log; the 500 error guide lists those messages. If you control the server, the Apache docs recommend putting the rules into the VirtualHost instead: .htaccess is read on every request, while the main config is read once. Remember the leading slash in the pattern when you move a rule there.
Test a redirect with curl -I
curl -sI http://example.com/old-page | grep -iE '^(HTTP|location)'
curl -sIL http://www.example.com/old-page | grep -iE '^(HTTP|location)' # follow the chain
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://example.com/product.php?id=42'HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-pageCheck three things: the status code, that Location is the final address (not a hop in between), and that a followed chain ends in 200 after one redirect. Test with a query string and with and without www too. On Windows, type curl.exe. More options are in curl command examples.
Redirect loops behind a proxy or CDN
When HTTPS ends before Apache (Cloudflare in Flexible mode, a load balancer, an nginx in front), every request reaches Apache over plain HTTP. %{HTTPS} is off each time, the HTTPS rule redirects forever, and the browser stops with ERR_TOO_MANY_REDIRECTS. Let the rule accept the scheme the proxy reports:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]A loop inside the server is different: Request exceeded the limit of 10 internal redirects in the error log means a rewrite (not a redirect) keeps matching its own result. In .htaccess the rule set runs again after a rewrite, so add a condition that stops the second pass, or use END instead of L.
Order, WordPress and mixing modules
- Redirects go at the top, above the
# BEGIN WordPressblock, which WordPress rewrites itself and which sends every unknown path toindex.phpbefore a rule further down could match. - Specific rules before general ones, because the first matching rule with
Lends the pass. - Do not mix
RedirectandRewriteRulefor the same URLs. The two modules do not run as one list in file order, so the order in the file does not decide which of them wins. Once a site uses mod_rewrite, keep its redirects there. - Escape dots in patterns:
^old\.php$, because a bare.matches any character.
nginx has no .htaccess
nginx reads no per-directory files, so .htaccess rules do nothing there and have to move into the server block. The usual equivalents:
# Redirect 301 /old-page https://example.com/new-page
location = /old-page {
return 301 https://example.com/new-page;
}
# Redirect 301 /old-folder https://example.com/new-folder
location /old-folder {
rewrite ^/old-folder(/.*)?$ https://example.com/new-folder$1 permanent;
}
# HTTP to HTTPS and www to non-www in one hop
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}$request_uri includes the query string; leave it out of the target to drop it. Then run sudo nginx -t && sudo systemctl reload nginx, as in restarting and reloading nginx. More nginx patterns are in the nginx part of the 301 guide. On VMHeaven’s Plesk hosting, forcing HTTPS is a checkbox in the panel rather than a rule you write, and on a Linux VPS you choose Apache or nginx yourself and can put the rules straight into the server config.
Frequently asked
How do I redirect a page with .htaccess?
Add Redirect 301 /old-page https://example.com/new-page to the .htaccess file in the document root. Redirect matches a path prefix, so it also catches /old-page/anything; for exactly one URL use RedirectMatch 301 ^/old-page/?$ https://example.com/new-page. Without a status code Apache sends a temporary 302.
How do I redirect HTTP to HTTPS in .htaccess?
Use mod_rewrite: RewriteEngine On, then RewriteCond %{HTTPS} off, then RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]. Behind Cloudflare in Flexible mode, a load balancer or an nginx that ends TLS, %{HTTPS} is always off and the rule loops: add RewriteCond %{HTTP:X-Forwarded-Proto} !https, or have the proxy talk HTTPS to the server.
Why does my .htaccess redirect not work?
Most often Apache ignores the file because AllowOverride is None, which is Apache's default and what Debian and Ubuntu set for /var/www; allow FileInfo in the virtual host. RewriteRule also needs mod_rewrite enabled (a2enmod rewrite), and in .htaccess its pattern must not start with a slash. A cached 301 in the browser can hide a fixed rule, so test with curl -I.
Does nginx support .htaccess files?
No. nginx reads no per-directory configuration files, so redirects go into the server block, for example location = /old-page { return 301 https://example.com/new-page; }. Test the config with nginx -t, then reload nginx.