14+ years building on WordPress / Replies in under 5 hours
WordPress 13 min read · Updated October 2026

Permanent 301 Redirect Using .htaccess: Code Examples and Fixes

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
TL;DR

To set a 301 redirect in .htaccess, add a line such as Redirect 301 /old-page /new-page/ above the # BEGIN WordPress block, or use RewriteRule with [R=301,L] when the redirect depends on the hostname, HTTPS or a query string. If it doesn't work, check that Apache reads .htaccess (AllowOverride), that mod_rewrite is loaded, that the rule sits above WordPress's rules, and that your browser isn't replaying a cached redirect. Test with curl -I and look for 301 plus the right Location header.

To add a permanent 301 redirect in .htaccess, put a line such as Redirect 301 /old-page /new-page/ in the .htaccess file in your site’s root folder, above the # BEGIN WordPress block, then check the response with curl -I. The table below has copy-paste code for the common cases, and the troubleshooting section covers what to check when a 301 redirect is not working. Every snippet in this guide was tested on Apache 2.4 before publishing.

What is the .htaccess code for a 301 redirect?

Apache gives you two ways to write a 301 redirect in .htaccess. Redirect 301 and RedirectMatch 301 come from mod_alias and match on the URL path. RewriteRule with the [R=301,L] flags comes from mod_rewrite and can also check the hostname, HTTPS and the query string. Swap in your own domain and paths.

What you want to redirect .htaccess code
One page to a new URL Redirect 301 /old-page /new-page/
One page to another domain Redirect 301 /old-page https://newsite.com/new-page/
A folder and everything in it Redirect 301 /old-folder /new-folder
A URL pattern, e.g. dated permalinks to /post-name/ RedirectMatch 301 ^/(\d{4})/(\d{2})/([^/]+)/?$ https://example.com/$3/
One page, using RewriteRule RewriteEngine On
RewriteRule ^old-page/?$ https://example.com/new-page/ [R=301,L]
HTTP to HTTPS RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Non-www to www RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]
www to non-www RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
HTTPS and www in one redirect RewriteEngine On
RewriteCond %{HTTPS} !=on [OR]
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]
HTTPS and non-www in one redirect RewriteEngine On
RewriteCond %{HTTPS} !=on [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
A whole domain to a new domain RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?olddomain\.com$ [NC]
RewriteRule ^ https://newdomain.com%{REQUEST_URI} [R=301,L]
Add a trailing slash RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^ %{REQUEST_URI}/ [R=301,L]
Remove a trailing slash RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [R=301,L]
Old .html pages to clean URLs RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.+)\.html$ /$1/ [R=301,L]
An old URL with a query string (?id=12) RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)id=12(&|$)
RewriteRule ^product\.php$ /products/blue-widget/ [R=301,L,QSD]

RewriteEngine On only needs to appear once, before your first RewriteRule. Repeating it does no harm, so each row above works on its own. All of these rules assume the .htaccess file sits in the web root.

Redirect 301: single pages and folders

Redirect is the simplest option and doesn’t need RewriteEngine. The old path must start with a slash. The target can be a full URL or a path; with a path, Apache adds your current scheme and hostname.

Redirect matches by prefix, in whole path segments. Redirect 301 /old-folder /new-folder also sends /old-folder/page/ to /new-folder/page/, but it leaves /old-folder-2/ alone. Any query string on the request is carried over to the target. Two details catch people out:

  • A trailing slash on the old path makes it stricter. Redirect 301 /old-page/ /new-page/ does not match a request for /old-page. Write the old path without the slash and both versions redirect.
  • The old path is case-sensitive, and a path with spaces must be in quotes: Redirect 301 "/old page.html" /new-page/. Without the quotes Apache returns a 500 error.

RedirectMatch 301: patterns

RedirectMatch works like Redirect but takes a regular expression, matched against the full path including its leading slash. Captured groups go into the target as $1, $2 and so on. The dated-permalink example in the table turns /2019/05/my-post/ into /my-post/, which is what you need after switching WordPress from date-based permalinks to post-name permalinks.

Always anchor the pattern with ^ and $. An unanchored RedirectMatch 301 /old https://example.com/new also matches /blog/old-news/, because the pattern can match anywhere in the path.

RewriteRule [R=301,L]: hosts, HTTPS and query strings

Use RewriteRule when the redirect depends on something other than the path. RewriteCond lines above a rule add conditions, such as the hostname (%{HTTP_HOST}), whether the request came over HTTPS (%{HTTPS}) or the query string (%{QUERY_STRING}). The flags at the end control the result:

  • R=301 sends a permanent redirect. A plain [R] sends a 302, which is a common reason a “301” turns out to be temporary.
  • L stops processing the remaining rules. Apache’s docs say you will almost always want [R] together with [L].
  • NC makes the match case-insensitive.
  • QSD drops the original query string from the target (Apache 2.4.0 and later). Without it, the query string is passed through unchanged.

The host rules in the table put %{REQUEST_URI} in the target, which carries the full path across, and the query string follows automatically because the target has no ? of its own. Combining the HTTPS and www checks into one rule with [OR] saves a hop: http://www.example.com/page goes straight to the final URL.

Moving a whole domain

The whole-domain rule goes in the .htaccess of the old domain and keeps every path, so olddomain.com/about/team/ lands on newdomain.com/about/team/. If the old domain has its own folder, Redirect 301 / https://newdomain.com/ does the same job in one line. Google’s site move guide says to keep the redirects “for as long as possible, generally at least 1 year” (source). A domain move has more steps than the redirect itself, so read the WordPress migration checklist before you switch.

Trailing slashes and file extensions

On WordPress, leave trailing slashes to your permalink setting, which already redirects the other version. Apache’s mod_dir adds the slash for real directories by default, so the trailing-slash rules are for static sites or custom setups. The .html rule skips files that still exist on disk, which helps when you’re moving an old static site into WordPress page by page.

Where do 301 redirects go in a WordPress .htaccess file?

Put your redirect rules at the top of .htaccess, above the # BEGIN WordPress line. WordPress manages everything between # BEGIN WordPress and # END WordPress, and its last rule sends every URL that isn’t a real file to index.php. A RewriteRule placed below that block never sees the old URL.

Here is a complete example. Page-level redirects come first and use a full https:// target, so a visitor on http://www. reaches the new page in one hop. The HTTPS and non-www rule comes next, then WordPress’s own block from the WordPress docs.

# Redirects: keep this section above # BEGIN WordPress
RewriteEngine On

# 1. Page and folder redirects
RewriteRule ^old-page/?$ https://example.com/new-page/ [R=301,L]
RewriteRule ^old-folder(/.*)?$ https://example.com/new-folder$1 [R=301,L]

# 2. Force HTTPS and non-www in one hop
RewriteCond %{HTTPS} !=on [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

If you add redirects after a launch or a redesign, the post-launch checklist shows where they fit with the other launch-day checks.

Why is my 301 redirect not working in .htaccess?

When a 301 redirect in .htaccess does nothing, Apache is usually ignoring the file, the module behind the rule isn’t loaded, the rule sits below WordPress’s block, or the pattern doesn’t match the URL Apache sees. A cached redirect in your browser or CDN can also hide a fix that already works. Go through these checks in order.

1. Apache is ignoring .htaccess (AllowOverride None)

Apache only reads .htaccess when the server config allows it. The default for AllowOverride is None, which means the file is ignored completely. Redirect and RewriteRule both need at least AllowOverride FileInfo. The Apache .htaccess guide has a quick test: type a nonsense word such as TestMe on its own line in the file and reload the site. A 500 error means Apache reads the file (remove the word straight away). No error means it’s ignored, and you need your host to change the setting.

2. The server runs Nginx

Nginx doesn’t read .htaccess files at all. Run curl -I https://example.com/ and look at the Server header; some hosts hide it, so ask support if it’s missing. On Nginx the redirect goes in the server block, for example return 301 https://example.com$request_uri;, as shown in the Nginx guide to converting rewrite rules.

3. mod_rewrite isn’t loaded, or FollowSymLinks is off

If mod_rewrite isn’t loaded, a bare RewriteEngine On causes a 500 error, and the error log shows Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration. Rules wrapped in <IfModule mod_rewrite.c> fail silently instead. mod_rewrite in .htaccess also needs the FollowSymLinks or SymLinksIfOwnerMatch option; without it, requests return 403 Forbidden. Redirect and RedirectMatch come from mod_alias, so they keep working in both cases.

4. The rule sits below the WordPress block

A RewriteRule placed under # END WordPress never fired for an old URL, because WordPress had already rewritten the request to index.php. Move your rules above # BEGIN WordPress. A plain Redirect 301 line still worked from the bottom of the file, but keeping all redirects in one section at the top makes them easier to find later.

5. Redirect and RewriteRule are mixed

Apache’s “When not to use mod_rewrite” page warns that mixing the two modules makes the execution order depend on context, and the technical docs advise choosing one module for a task. In my test on Apache 2.4.67, a RewriteRule redirect won over a Redirect for the same URL even when the Redirect line came first in the file. WordPress already uses mod_rewrite, so on a WordPress site I write every redirect as a RewriteRule.

6. The pattern doesn’t match the URL Apache sees

  • In .htaccess, a RewriteRule pattern is matched against the path without its leading slash. RewriteRule ^/old-page$ ... never matches; write ^old-page$.
  • Redirect is case-sensitive. For a case-insensitive match, use a RewriteRule with [NC].
  • A trailing slash on the old path in a Redirect line means the version without the slash won’t match.
  • Only the path is matched. Domain names belong in a RewriteCond %{HTTP_HOST} line.

7. Regex characters aren’t escaped

In a regular expression, . matches any character, so ^www.example.com also matches wwwXexample.com. Escape dots as \., and escape ?, +, ( and ) when you mean them literally. Anchor RedirectMatch and RewriteRule patterns with ^ and $ so they don’t match in the middle of other URLs.

8. The old URL has a query string

Redirect, RedirectMatch and the RewriteRule pattern never see the part after ?, so Redirect 301 "/product.php?id=12" /new/ does nothing. Match the query string with RewriteCond %{QUERY_STRING} as in the last row of the table. The (^|&) and (&|$) around id=12 stop it from matching id=123, and QSD stops ?id=12 from being copied onto the new URL.

9. Your browser cached an earlier 301

Under RFC 9110, a 301 can be cached by default, so a browser may keep replaying an old or wrong redirect without asking your server. Test in a private window, with “Disable cache” ticked in DevTools, or with curl, which keeps no cache. While you’re still adjusting a rule, use 302 and switch to 301 once it’s right.

10. A cache plugin, CDN or proxy answers first

Some cache plugins, such as WP Rocket, add their own blocks to .htaccess, and a CDN can serve a stored copy of the old URL before the request reaches Apache. Clear the plugin cache and purge the CDN after changing redirects. If HTTPS ends at a proxy or load balancer, Apache sees every request as plain HTTP, %{HTTPS} is never on, and the HTTPS rule loops. Apache’s redirect recipes suggest checking the X-Forwarded-Proto header instead, and only when you control the proxy:

RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !https [NC]
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

If the browser shows “too many redirects”, follow the steps in fixing ERR_TOO_MANY_REDIRECTS in WordPress.

11. The site shows a 500 error after the edit

A 500 right after saving almost always means a syntax error, such as an unquoted path with a space or a missing bracket in the flags. Restore your backup first, then check the error log, which names the line. The guide to fixing a 500 Internal Server Error in WordPress covers the other causes.

How do I test a 301 redirect?

The quickest test is curl -I followed by the old URL, which prints the status code and the Location header the server returns. A working redirect shows 301 Moved Permanently and the new URL. Curl doesn’t cache redirects, so it shows what your server is sending right now.

curl -I https://example.com/old-page

Apache’s response starts like this (HTTP/2 hosts show HTTP/2 301 and a lowercase location:):

HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-page/

To follow the whole chain and count the hops, use curl -sIL https://example.com/old-page. Each block in the output is one hop, and you want one 301 followed by a 200. Here is how to read the status codes:

  • 301 with the right Location: the redirect works.
  • 302: the rule is missing 301, or uses [R] without =301.
  • 200 or 404 on the old URL: the rule isn’t matching, or Apache isn’t reading the file.
  • 403: mod_rewrite is blocked because FollowSymLinks is off.
  • 500: syntax error. Restore your backup.

In a browser, open DevTools, go to the Network tab, tick “Disable cache” and load the old URL. The first request should show 301 and the second should show 200 for the new page.

What is the difference between 301, 302, 307 and 308 redirects?

301 and 308 are permanent redirects; 302 and 307 are temporary. Google treats a permanent redirect as a strong signal that the new URL should appear in search results, and a temporary one as a weak signal. 307 and 308 also forbid the browser from changing a POST request into a GET.

Code Type Request method .htaccess syntax
301 Permanent Some clients change POST to GET Redirect 301 or [R=301,L]
302 Temporary Some clients change POST to GET Redirect 302, plain Redirect, or [R=302,L]
307 Temporary Method and body kept Redirect 307 or [R=307,L]
308 Permanent Method and body kept Redirect 308 or [R=308,L]

Google’s redirects documentation treats 301 and 308 the same way, and 302 and 307 the same way. The method details come from MDN’s redirections guide. For pages that have moved for good, use 301. Use a 302 for a redirect you plan to remove, such as a seasonal landing page. Both Redirect with no status code and RewriteRule with a plain [R] send a 302 by default, so always write the number.

Should you use a plugin or .htaccess for redirects?

On WordPress, a plugin such as Redirection is the safer choice for one-off page redirects. It stores them in the database, gives you a screen to add and edit them, and logs 404s so you can see what still needs redirecting. A typo there can’t take the site down the way a bad .htaccess line can.

Use .htaccess for rules that apply to the whole site and should run before WordPress loads: HTTPS, www or non-www, domain moves, and pattern redirects covering hundreds of old URLs. It’s also the only option on a static site. For a large redesign or platform move, I build the full redirect map before launch and test every old URL against it. If you’d rather hand that part off, my WordPress migration service includes the redirect map and testing.

Frequently asked questions

Change the status code. Write Redirect 302 /old-page /new-page/ with mod_alias, or end a RewriteRule with [R=302,L]. A Redirect line with no status code and a RewriteRule with a plain [R] flag both send a 302 already, which is why redirects meant to be permanent sometimes turn out to be temporary. Use 302 or 307 only for a redirect you plan to remove later.

It is the status line Apache sends when a Redirect 301 or a RewriteRule with [R=301] matches. The response carries a Location header with the new URL, and the browser requests that URL straight away. If the page is opened directly, Apache's default body reads "Moved Permanently. The document has moved here." with a link to the new address.

Google treats a 301 as a strong signal that the new URL should replace the old one in search results, so the new page can take over the old page's place. Rankings also depend on the new page itself, so map each old URL to the closest matching new page and keep the chain to a single hop.

Google's site move guidance says to keep redirects for as long as possible, and generally for at least a year. Visitors also arrive through old bookmarks, emails and links on other sites long after Google has updated its index. My advice is to leave them in place indefinitely unless they clash with new URLs, since a few extra lines in .htaccess cost almost nothing.

No. Redirect and RedirectMatch only compare the path, so anything after the question mark is invisible to them. Use mod_rewrite instead: a RewriteCond %{QUERY_STRING} line checks the parameter, and the RewriteRule below it matches the path. Add the QSD flag if the old parameters shouldn't be copied onto the new URL. QSD needs Apache 2.4.0 or later.

No. Nginx never reads .htaccess files, so rules placed there have no effect. On an Nginx server the redirect goes in the server block of the site's config, for example return 301 https://example.com$request_uri; for a whole-site redirect. If you don't have access to that config, ask your host to add the redirect or use a WordPress redirect plugin.

A 500 straight after an edit means Apache can't parse a line. Common causes are a path with spaces that isn't in quotes, a typo in the flags, or RewriteEngine used on a server without mod_rewrite. Restore the backup to bring the site back, then read the server error log, which names the problem line and the directive Apache didn't accept.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance, the kind of work that shows up as faster load times and fewer support tickets. You work directly with me, and replies typically arrive within 5 hours.

Work with me →