Contents
Got a question about your site?
Book a short consultation – I will tell you where to start.
To set up a 301 redirect, the server has to respond to a request for the old URL with a 301 Moved Permanently status code and a Location header containing the new URL. On Apache or LiteSpeed hosting, one line in the .htaccess file is enough; in Nginx it’s the return 301 directive, in PHP the header() function with a 301 code, and in WordPress the Redirection plugin. I check every redirect after deployment with curl -I: the old URL should return a 301 and point straight to the target URL, with no intermediate hops.
Below you’ll find ready-made, tested configuration snippets to copy: a single URL, a whole domain, www and non-www, HTTP to HTTPS, Nginx, PHP, WordPress and Cloudflare. On top of that, a short cheat sheet on when to use a 301 for SEO and when a 302, 307 or 308, plus the mistakes I see most often in audits. This is a hands-on guide. If you want to understand the codes themselves (what the HTTP specification says, how they behave with caching and request methods, what a 303 or 304 is), have a look at my overview of 3xx redirect status codes.
Table of contents
- What is a 301 redirect and how does it work?
- 301, 302, 307 or 308 – which redirect should you use?
- How to set up a 301 redirect – step by step
- 301 redirect in .htaccess (Apache and LiteSpeed)
- 301 redirect in Nginx
- 301 redirect in PHP
- 301 redirect in WordPress
- 301 redirect in Cloudflare
- Redirects without server access: meta refresh and JavaScript
- Common 301 redirect mistakes
- How to check a 301 redirect
- FAQ – 301 redirects
What is a 301 redirect and how does it work?
A 301 redirect is an HTTP server response with the 301 Moved Permanently status code that tells the browser and Googlebot: ‘this resource has permanently moved to a different address, use the new one from now on’. The browser doesn’t show the old page’s content; it goes straight to the URL in the Location header. This is what it looks like in practice (response trimmed to the key headers):
$ curl -I --http1.1 https://example.com/old-page/
HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-page/
What matters most for SEO is that Google treats a permanent redirect (301 or 308) as a strong signal that the target URL should be canonical and shown in the search results. Signals from the old URL, including links, are consolidated on the new one. That’s why I use a 301 for every permanent change: a new post URL, a change in URL structure, a move to HTTPS or moving the site to a new domain.

301, 302, 307 or 308 – which redirect should you use?
Nine times out of ten you need a 301. The choice comes down to one question: is the change permanent? If it is – a 301 (or a 308). If it’s only for a while – a 302 (or a 307). I’ve covered the specification, history and technical nuances of each code separately in my post on 3xx redirect status codes. Here’s the cheat sheet to help you decide:
| Code | Type | When to use |
|---|---|---|
| 301 | Permanent | A permanent change of address: new slug, new structure, HTTPS, new domain. The default choice in SEO. |
| 302 | Temporary | A short-term change: maintenance, a short promotion, a test. The old URL stays in the index. |
| 307 | Temporary | Like a 302, but the browser has to repeat the request with the same method (e.g. POST). For Google it’s the same as a 302. You’ll mainly come across it in APIs and JavaScript apps. |
| 308 | Permanent | Like a 301, but preserving the HTTP method. Google treats it the same as a 301. |
301 vs 302 for SEO: with a 301, Google treats the redirect as a strong signal that the target URL is canonical, so over time that’s the one that appears in the search results. With a 302 it’s only a weak signal, and the old URL usually stays in the results. 301 vs 308 and 302 vs 307: Google treats a 308 the same as a 301 and a 307 the same as a 302. The only difference is whether the browser is allowed to switch a POST request to GET, which matters for forms and APIs, not for ordinary pages.
302 redirect – what it is and how to set it up
A 302 redirect (302 Found) says: ‘the resource is temporarily at a different address, but keep coming back to the old one’. You use it when the change is short-lived: maintenance on a single page, a short promotion, an A/B test with separate URLs for the variants. You set it up exactly like a 301, you just change the code:
# .htaccess (Apache, LiteSpeed)
Redirect 302 /promo/ https://example.com/autumn-promo/
# Nginx
location = /promo/ {
return 302 https://example.com/autumn-promo/$is_args$args;
}
# PHP
<?php
header('Location: https://example.com/autumn-promo/', true, 302);
exit;
Watch out for the opposite situation: many tools (PHP, wp_redirect(), the R flag in mod_rewrite) send exactly that – a 302 – when you don’t specify a code, even though the change is permanent. I come back to this in the section on mistakes. If a ‘temporary’ change ends up staying for good, switch the 302 to a 301 rather than hoping Google will work it out on its own.
307 and 308 redirects – when and how
307 (Temporary Redirect) is a temporary redirect where the browser has to keep the request method. 308 (Permanent Redirect) is its permanent counterpart. On an ordinary company website or blog I rarely set them up. They come in handy when you move endpoints that accept forms or API requests sent with POST, and the data mustn’t get lost on the way. The syntax is identical to a 301:
# .htaccess (Apache, LiteSpeed)
Redirect 307 /api/order https://example.com/api/v2/order
Redirect 308 /api/cart https://example.com/api/v2/cart
# Nginx
location = /api/cart {
return 308 https://example.com/api/v2/cart$is_args$args;
}
Sometimes 307s and 308s turn up on their own. For redirects in next.config.js, Next.js sends a 308 for permanent: true and a 307 for permanent: false. It makes no difference to Google, so you don’t need to ‘fix’ them to 301s. And the ‘307 Internal Redirect’ you see in Chrome’s developer tools usually isn’t your server at all, but the browser’s HSTS mechanism. I explain it in my post on 3xx codes.
How to set up a 301 redirect – step by step
- Make a list of the old URLs. Export them from a crawler (e.g. Screaming Frog), the sitemap, the Performance report in Google Search Console and the list of URLs with external links.
- Match every old URL with its counterpart. A redirect should lead to a page with the same or very similar content, not wholesale to the homepage.
- Choose where to implement it. Ideally on the server (
.htaccess, Nginx configuration) or at the CDN edge (Cloudflare). A CMS plugin is fine too. JavaScript and meta refresh are a last resort. - Deploy the rules and test them straight away (
curl -I, described below). Back up.htaccessbefore editing it, because a single typo can take down the whole site with a 500 error. - Update internal links, the sitemap and canonical tags so that they point directly to the new URLs. A redirect is there to catch traffic from outside, not to handle navigation within your site.
- Keep redirects for at least a year, and ideally indefinitely. When moving a site, Google recommends keeping them ‘for as long as possible, generally at least 1 year’, and from the users’ point of view even permanently.
301 redirect in .htaccess (Apache and LiteSpeed)
The .htaccess file sits in the site’s root directory (usually public_html). It’s supported by Apache and LiteSpeed, so the examples below work on most shared hosting. In WordPress, paste the rules above the # BEGIN WordPress block, because WordPress overwrites everything between the # BEGIN and # END markers. Order matters too: the rule has to fire before WordPress picks up the request.
Redirecting a single URL
The simplest option is the Redirect directive from the mod_alias module:
Redirect 301 /old-page/ https://example.com/new-page/
Remember that Redirect matches the beginning of the path (whole segments) and appends the rest. The rule above will also redirect /old-page/something to /new-page/something. Query parameters (?id=12) are carried over to the new URL. If you want to redirect exactly one URL, use RedirectMatch with a regular expression:
RedirectMatch 301 ^/old-page/?$ https://example.com/new-page/
On sites that already use mod_rewrite (WordPress, PrestaShop and most CMSs), I prefer to keep all redirects in one module. In that case RewriteRule gives the same result. Note that in .htaccess the path in the rule doesn’t start with a slash:
RewriteEngine On
RewriteRule ^old-page/?$ https://example.com/new-page/ [R=301,L]
Redirecting a whole domain to a new domain
When you change domains, every URL should land on its counterpart on the new domain, keeping the path. You put this code in the .htaccess of the old domain:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?old-domain\.com$ [NC]
RewriteRule ^ https://new-domain.com%{REQUEST_URI} [R=301,L]
%{REQUEST_URI} carries over the path, and Apache appends the query parameters by default. That way old-domain.com/offer/?utm_source=x lands on new-domain.com/offer/?utm_source=x. If you’re only moving part of the site, e.g. the /en/ language version to a separate domain, the rule looks like this:
RewriteEngine On
RewriteRule ^en(/.*)?$ https://example.co.uk$1 [R=301,L]
Redirecting www to non-www and HTTP to HTTPS
It’s worth handling the www and non-www versions, as well as HTTP and HTTPS, with a single rule, so that every variant reaches the target URL in one hop. Target version https://example.com (without www):
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
Target version https://www.example.com (with www):
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]
If you only need to force HTTPS without changing the host, this is enough:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Two notes from practice. First, the SSL certificate has to cover both host versions (with and without www). Otherwise a user visiting https://www.example.com will see a browser warning before the redirect even kicks in. Second, if the site sits behind Cloudflare in ‘Flexible’ SSL mode or behind another proxy server, the %{HTTPS} off condition is always true and the rule ends up in a loop. In that set-up I configure the HTTPS redirect on the Cloudflare side (described below).
Changing the URL structure (regular expressions)
Regular expressions let you handle hundreds of URLs with a single rule. Example: a WordPress site with dated URLs (/2024/08/04/post-name/) switches to URLs with just the post name:
RewriteEngine On
RewriteRule ^[0-9]{4}/[0-9]{2}/[0-9]{2}/(.+)$ https://example.com/$1 [R=301,L]
Second example: the old site had URLs ending in .html, and the new URLs are handled by a CMS without the extension:
RewriteEngine On
RewriteRule ^(.+)\.html$ https://example.com/$1 [R=301,L]
Only use this second rule if there are no longer any physical files behind the .html URLs that the server relies on. Otherwise you’ll create a loop.
The most important RewriteRule flags
| Flag | What it does |
|---|---|
R=301 | Returns a redirect with a 301 code. R on its own, without a number, means 302. |
L | Stops processing rules after a match. For redirects, always use it together with R. |
NC | Case-insensitive matching. |
QSA | Appends the original query parameters when you specify your own parameters in the target URL. |
QSD | Discards the query parameters from the target URL (Apache 2.4+). |
NE | Turns off escaping of special characters, e.g. when you redirect to a #section anchor. |
If you want to signal that a page is gone with no successor, don’t redirect it – return a 410 code instead, e.g. Redirect gone /old-promo/. More on this in my post on 4XX status codes.
301 redirect in Nginx
Nginx doesn’t read .htaccess files. You add redirects to the server block configuration, then check the syntax and reload the server: nginx -t && systemctl reload nginx. On shared hosting you usually don’t have access to these files. Then you’re left with the hosting control panel, .htaccess (if Apache runs behind Nginx) or a redirect in the CMS. Not sure what your site runs on? I’ve covered the differences in my comparison of Apache, Nginx and LiteSpeed.
A single URL
location = /old-page/ {
return 301 https://example.com/new-page/$is_args$args;
}
location = matches exactly that one URL. $is_args$args carries over the query parameters. Without it, return drops them.
HTTPS and the non-www version in one hop
# HTTP (with and without www) -> https://example.com
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# HTTPS with www -> https://example.com
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
# The actual site
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ... rest of the configuration
}
The $request_uri variable contains the path together with the parameters, so nothing gets lost. Note that the redirect from HTTP targets https://example.com directly rather than $scheme://. That way http://www.example.com doesn’t go through an intermediate URL. When changing domains, you put the old domain in server_name and the new one in return.
Redirecting many URLs by pattern
location ~ "^/[0-9]{4}/[0-9]{2}/[0-9]{2}/(.+)$" {
return 301 https://example.com/$1$is_args$args;
}
An expression containing curly braces {} has to be in quotes, otherwise Nginx reports a syntax error. With a large number of individual redirects (e.g. hundreds of URLs after migrating an online shop), the map directive works better than hundreds of location blocks.
301 redirect in PHP
In PHP you redirect with the header() function. An important trap: a Location header on its own makes PHP send a 302 code. You have to specify the 301 code explicitly in the third argument:
<?php
header('Location: https://example.com/new-page/', true, 301);
exit;
header() has to be called before the script sends any output (even a blank line before <?php). I always put exit after it so the rest of the script doesn’t run.
301 redirect in WordPress
In WordPress you have three options, starting with the simplest:
- Changing a post’s slug. When you change the URL of a published post, WordPress remembers the old slug and redirects it with a 301 on its own. This works for posts and other non-hierarchical content types, but not for pages (the ‘Page’ type is hierarchical). After changing a page’s URL you have to add the redirect yourself, just as when you change the permalink structure, categories or domain.
- The Redirection plugin (free, over 2 million installs). Under Tools → Redirection you add the source URL, the target URL and the 301 code. The plugin supports regular expressions, logs 404 errors and can export rules to
.htaccessor Nginx format. Yoast SEO also has a redirect manager, but only in the Premium version. - Your own code in a child theme or a must-use plugin (mu-plugin), when you only have a few redirects and don’t want yet another plugin.
<?php
add_action( 'template_redirect', function () {
$path = trim( (string) wp_parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ), '/' );
if ( 'old-page' === $path ) {
wp_safe_redirect( home_url( '/new-page/' ), 301 );
exit;
}
} );
The wp_redirect() and wp_safe_redirect() functions send a 302 code by default, so you have to specify 301 explicitly. They also don’t stop the script, so they’re always followed by exit.
301 redirect in Cloudflare
If your domain runs through Cloudflare, you can set up the redirect at the network edge, before the request reaches your server. The condition: the DNS record has to be proxied (the orange cloud).
- Redirect Rules (Single Redirects) – rules for a single domain, including dynamic ones with the
*wildcard. You create them under Rules → Overview → Create rule → Redirect Rule. Example www → non-www: patternhttps://www.*, target URLhttps://${1}, code301, Preserve query string ticked. - Bulk Redirects – account-level lists of static ‘old URL → new URL’ pairs, including for multiple domains. They don’t support regular expressions, but they’re great for migrations with a ready-made URL map from a spreadsheet (CSV import).
- Always Use HTTPS – a toggle under SSL/TLS → Edge Certificates that redirects all HTTP traffic to HTTPS.
If you set up redirects in Cloudflare, don’t duplicate them on the server. In ‘Flexible’ SSL mode Cloudflare connects to the server over HTTP, and a server that forces HTTPS sends it straight back. The result is an ERR_TOO_MANY_REDIRECTS error. The fix is a certificate on the server and ‘Full (strict)’ mode.
Redirects without server access: meta refresh and JavaScript
When you have no control over the server at all (e.g. a simple site on static hosting), you’re left with browser-side redirects. These aren’t HTTP 301 redirects, but Google recognises them. It treats an instant meta refresh (content="0; …") as a permanent redirect and a delayed one (e.g. after 5 seconds) as a temporary one. Google only recommends JavaScript when neither a server-side redirect nor a meta refresh is possible, because it requires rendering the page.
<meta http-equiv="refresh" content="0; url=https://example.com/new-page/">
<script>
window.location.href = "https://example.com/new-page/";
</script>
Common 301 redirect mistakes
Redirect chains
A → B → C → D instead of going straight from A → D. Every hop adds delay for the user and an extra request for the crawler. By default Googlebot follows up to 10 hops, but Google recommends pointing straight to the final URL, and if that’s not possible, keeping the chain to ideally no more than 3 hops (and definitely fewer than 5). Chains usually appear when you redirect HTTP → HTTPS and www → non-www separately, or add new rules with each migration without updating the old ones.
Redirect loops
A URL redirects to itself, or two URLs redirect to each other. The browser then shows a ‘too many redirects’ error. Typical causes are conflicting rules in .htaccess and a plugin, contradictory site address settings in WordPress (with and without www) and the Cloudflare ‘Flexible’ mode mentioned above.
302 instead of 301
The change is permanent, but the server responds with a 302. The most common culprits: header('Location: ...') in PHP without a code, wp_redirect() without the second argument, the [R] flag without =301 and the Redirect directive without a code in .htaccess.
Redirecting everything to the homepage
During a migration it’s tempting to use one rule: ‘everything to /’. Google warns that mass-redirecting many old URLs to a single unrelated page, such as the homepage, may be treated as a soft 404. The signals from the old pages are then simply lost. Redirect every important URL to its topical counterpart. If there’s no counterpart, return a 404 or 410.
Other mistakes I see often
- Internal links, menus, the sitemap and canonical tags still point to the old URLs.
- Redirects removed after a few weeks because ‘Google already knows’. Keep them for at least a year.
- The rule drops the path or query parameters (e.g.
return 301 https://example.com;instead of using$request_uri). - Redirecting resources that should stay put, e.g.
robots.txtor sitemap files on the new domain.
Problems like these usually only come to light when you crawl the whole site. If you’re planning a migration or suspect your redirects are eating your traffic, I check this as part of an SEO audit.
How to check a 301 redirect
curl in the terminal
The quickest and most reliable test. curl -I fetches only the headers and doesn’t follow the redirect, so you see exactly what the server returns:
curl -I http://www.example.com/old-page/
You’re expecting a 301 code and a Location header with the target URL. Adding -L (curl -IL) shows the whole chain all the way to the final page. If you see more than one 3xx code, you have a chain. I check all the domain versions with one command, and each of them should reach the same URL in a single hop:
for u in http://example.com http://www.example.com https://www.example.com https://example.com; do
curl -s -o /dev/null -w "%{url_effective} -> %{http_code} %{redirect_url}\n" "$u/"
done
Browser developer tools
F12 → the Network tab, tick Preserve log and visit the old URL. In the Status column you’ll see the 301 and the next request. Browsers remember 301 redirects for a long time, so test in an incognito window or with the cache disabled. Otherwise you may be looking at an old redirect that no longer exists on the server.
Online tools and crawlers
- httpstatus.io and Redirect Checker – show the status code and the full redirect path, including for many URLs at once.
- Screaming Frog SEO Spider – after crawling the whole site, use the Response Codes → Redirection (3xx) filter and the redirect chains report. During a migration I load the list of old URLs into it in List mode and check that each one ends with a 200 on the right page.
- Your own script – with several thousand URLs, it’s quicker to check them yourself. I’ve described a ready-made example in my post on bulk-checking HTTP status codes with Python.
Google Search Console

In Google Search Console, paste the old URL into the URL Inspection tool. You’ll see whether Google knows about the redirect and which URL it has chosen as canonical. In the Indexing → Pages report, redirected URLs end up in the ‘Page with redirect’ group. That’s normal and not an error, as long as the only URLs there are ones that were actually meant to be redirected. After a migration, submit the new sitemap and keep an eye on the Performance report for the old and new URLs for a few weeks.
FAQ – 301 redirects
What’s the difference between a 301 and a 302 redirect?
A 301 means a permanent change: over time Google shows the new URL in the results and transfers signals to it. A 302 means a temporary change: Google follows the redirect but usually keeps the old URL in the results. Technically you set them up the same way – only the number in the rule changes.
Does a 301 redirect pass ‘SEO value’ to the new URL?
Yes. With a permanent redirect, Google treats the target URL as canonical and consolidates signals on it, including links pointing to the old URL. The condition: the new page has to match the old one in content. A redirect to an unrelated page may be treated as a soft 404.
How long should you keep a 301 redirect?
Google recommends at least a year. In practice I leave redirects in place indefinitely, because old external links, bookmarks and results in other search engines live on much longer.
How long does it take Google to pick up a redirect?
Only once the crawler revisits the old URL. On frequently crawled pages that takes days; on rarely crawled ones, weeks. You can speed it up with a sitemap containing the new URLs and good internal linking. More on this in my post on crawl budget.
Can a 301 redirect be undone?
On the server, yes – just remove the rule. Browsers that have already visited the old URL may, however, remember the redirect in their cache for a long time. That’s why, for changes you’re not sure about, start with a 302 and switch it to a 301 once the decision is final.
Will .htaccess rules work on an Nginx server?
No. Nginx ignores .htaccess files, so the rules have to be rewritten in Nginx syntax (examples above) or moved to a plugin or Cloudflare. The exception is hosting where Nginx only acts as a proxy in front of Apache. There, .htaccess still works.
Redirects are one of those technical SEO elements that decide whether you keep the traffic you’ve built up when you make changes to your site. If you’re preparing a migration, a domain change or a rebuild of your URL structure, I can prepare a redirect map and check the implementation as part of my technical SEO work.






Leave a Reply