How .htaccess redirects work with mod_rewrite
The .htaccess file is one of the most powerful tools in a WordPress administrator’s arsenal. This configuration file allows you to override server settings at the directory level, giving you granular control over URL redirections, access restrictions, and performance optimizations.
What is mod_rewrite
mod_rewrite is an Apache module that provides a rule-based rewriting engine to rewrite requested URLs on the fly. It’s the engine behind WordPress’s pretty permalinks and is essential for modern SEO-friendly URL structures.
Key benefits:
- SEO Preservation: Redirect old URLs to new ones without losing search rankings
- User Experience: Ensure visitors always reach the correct content
- Security: Force HTTPS and block malicious requests
- Performance: Implement caching headers and compression
When to use 301 vs 302 redirects
Understanding HTTP status codes is crucial for proper SEO implementation:
| Redirect Type | Use Case | SEO Impact |
|---|---|---|
| 301 Permanent | Content moved permanently, domain changes, HTTPS migration | Google uses it as a canonical signal and shows the target URL in results |
| 302 Found | Temporary maintenance, A/B testing, short-term content moves | Googlebot follows it, but it is not a canonical signal, so the original URL usually stays in results |
| 307 Temporary | Strict HTTP/1.1 temporary redirect, POST data preservation | Same as 302 but preserves HTTP method |
| 308 Permanent | Strict HTTP/1.1 permanent redirect, POST data preservation | Same as 301 but preserves HTTP method |
Golden rule: If the change is permanent, always use 301. With a 302 for a permanent move, the old URL may stay in search results instead of the new one (Redirects and Google Search).
Common .htaccess 301 redirect examples
1. Redirect a single page
The simplest form of redirection moves one specific URL to another:
# Redirect a single page (simple syntax)
Redirect 301 /old-page.html https://www.domain.com/new-page.html
# Alternative using RewriteRule
RewriteEngine on
RewriteRule ^old-page\.html$ /new-page.html [R=301,L]When to use each:
- Use
Redirect 301for simple, exact URL matches - Use
RewriteRulewhen you need pattern matching or conditions
2. Redirect an entire site to a new domain
Moving your entire website to a new domain requires careful handling:
# Redirect entire site to new domain
Redirect 301 / https://www.newdomain.com/
# Preserve full URL structure
RewriteEngine on
RewriteBase /
RewriteRule (.*) https://www.newdomain.com/$1 [R=301,L]Important: Always test with a few URLs first before implementing site-wide redirects.
3. Redirect a subfolder to root (or vice versa)
Common when restructuring content:
# Redirect entire site to a subfolder (skips URLs already in it, so it does not loop)
RedirectMatch 301 ^/(?!subfolder/)(.*)$ https://www.domain.com/subfolder/$1
# Redirect subfolder to another site (trailing slash on both sides avoids a double slash)
Redirect 301 /subfolder/ https://www.domain.com/
# Using RewriteRule with full path preservation
RewriteEngine on
RewriteRule ^subfolder/(.*)$ /$1 [R=301,L]A plain Redirect 301 / https://www.domain.com/subfolder/ on the same host loops: Redirect is a prefix match, and the target path also starts with /. And because Redirect appends the path after the matched part, Redirect 301 /subfolder https://www.domain.com/ would turn /subfolder/page into https://www.domain.com//page (mod_alias).
4. Redirect .html to .php and remove extensions
When migrating from .html to .php or implementing pretty permalinks:
# Redirect .html to .php
RedirectMatch 301 (.*)\.html$ https://www.domain.com$1.php
# Remove .php extension (make URLs cleaner)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^([^\.]+)$ $1.php [NC,L]5. Force HTTPS (SSL)
Essential for security and SEO:
# Force HTTPS - Method 1 (simple)
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Force HTTPS - Method 2 (with www handling)
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\.
RewriteRule ^(.*)$ https://www.domain.com/$1 [L,R=301]6. Force www or non-www
Consistency in URL structure matters for SEO:
# Force www
RewriteEngine on
RewriteCond %{HTTP_HOST} ^domain.com [NC]
RewriteRule ^(.*)$ https://www.domain.com/$1 [L,R=301]
# Force non-www (naked domain)
RewriteEngine on
RewriteCond %{HTTP_HOST} ^www\.domain\.com [NC]
RewriteRule ^(.*)$ https://domain.com/$1 [L,R=301]RewriteRule 301 redirects with query strings and regex
How to redirect URLs with query strings
Handling URLs with parameters requires special attention:
# Redirect specific query parameter
RewriteEngine on
RewriteCond %{QUERY_STRING} (^|&)id=1(&|$)
RewriteRule ^index\.php$ /path-to-new-location/? [R=301,L]
# Map a parameter value into the path
RewriteEngine On
RewriteCond %{QUERY_STRING} ^category=([^&]+)
RewriteRule ^index\.php$ /category/%1/? [R=301,L]
# Remove query strings entirely
RewriteEngine On
RewriteCond %{QUERY_STRING} .
RewriteRule ^$ /? [R=301,L]Note: The trailing ? in the destination URL strips the original query string. Since Apache 2.4.0 the [QSD] flag does the same job (RewriteRule flags). A bare id=1 condition would also match id=10, id=123 and pid=1; the (^|&) and (&|$) anchors limit it to the exact parameter.
Regex pattern matching in RewriteRule
Regular expressions provide powerful pattern matching:
# Redirect date-based URLs
RewriteEngine On
RewriteRule ^blog/([0-9]{4})/([0-9]{2})/(.+)$ /news/$1/$2/$3 [R=301,L]
# Redirect with wildcards
RewriteEngine On
RewriteRule ^products/(.*)$ /shop/$1 [R=301,L]
# Multiple pattern matches
RewriteEngine On
RewriteRule ^(blog|news|articles)/(.+)$ /content/$2 [R=301,L]Domain change with path preservation
When migrating to a completely new domain:
# Old domain to new domain with full path
RewriteEngine On
RewriteCond %{HTTP_HOST} ^old-domain.com$ [OR]
RewriteCond %{HTTP_HOST} ^www.old-domain.com$
RewriteRule (.*)$ https://www.new-domain.com/$1 [R=301,L]
# With subdirectory adjustment
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/blog/(.*)$
RewriteRule ^(.*)$ https://www.newdomain.com/news/%1 [R=301,L]How to test .htaccess redirects before going live
What to check before a redirect goes live
- Use 302 Temporary First: Test with 302 redirects before making them permanent
- Check Redirect Chains: Ensure you’re not creating redirect loops
- Test Multiple Scenarios: Check with and without www, HTTP and HTTPS
- Validate with Tools: Use online redirect checkers
Test a 301 redirect with curl
# Using curl to check headers
curl -I http://yourdomain.com/old-page
# Follow redirects with curl
curl -L http://yourdomain.com/old-page
# Check specific status codes
curl -I -L http://yourdomain.com/old-page 2>&1 | grep HTTPOnline redirect checker tools
- Redirect checker: httpstatus.io
- Google Search Console: Check for crawl errors after implementation
Troubleshooting redirect loops
What causes a redirect loop
- Circular References: Rule A redirects to B, Rule B redirects back to A
- Protocol Mismatches: HTTP rule conflicting with HTTPS forcing
- WWW Conflicts: www and non-www rules fighting each other
- WordPress Conflicts: Plugin redirects conflicting with .htaccess
Log mod_rewrite to find the loop
# Server or virtual host config: LogLevel is not allowed in .htaccess
LogLevel alert rewrite:trace3RewriteLog and RewriteLogLevel were removed in Apache 2.4 and replaced by the per-module LogLevel setting (mod_rewrite). The same page warns that levels above trace2 slow the server dramatically, so use them only for debugging and remove the line afterwards. To see only the rewrite entries: tail -f error_log | fgrep '[rewrite:'.
How to stop a redirect loop in .htaccess
# Prevent loops by checking if already redirected
RewriteEngine On
RewriteCond %{ENV:REDIRECT_STATUS} 200
RewriteRule ^ - [L]
# Specific protocol handling to avoid loops
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]How Google treats 301, 302, 307 and 308 redirects
Google’s documentation on redirects (Redirects and Google Search) splits redirects into two groups. Permanent redirects (301 and 308) are followed by Googlebot, and the indexing pipeline uses them as a signal that the target should be canonical, so the new URL is what appears in results. Temporary redirects (302, 303 and 307) are followed too, but they are not used as a canonical signal, so the source URL usually stays in results. Google also ranks the methods by how reliably it can interpret them: a server-side redirect such as one from .htaccess comes first, ahead of meta refresh and JavaScript.
This matters for .htaccess because both Apache modules default to the temporary code. Redirect without a status argument sends a 302 (mod_alias), and RewriteRule with a bare [R] flag also sends a 302 (RewriteRule flags). A migration written with [R,L] instead of [R=301,L] ships temporary redirects without any error message.
Chains have a hard limit too. Google’s crawlers follow up to 10 redirect hops by default (HTTP status codes), and the site-move guide asks for no more than 3 hops and fewer than 5 at most, because each hop adds latency (site moves with URL changes). The same guide says to keep migration redirects for as long as possible, generally at least one year.
Why a RewriteRule with a leading slash does not match in .htaccess
The most common reason a rule copied from a server config does nothing in .htaccess is the leading slash. In per-directory context (.htaccess and <Directory>), Apache strips the directory path, up to and including the trailing slash, before it compares the pattern. The mod_rewrite documentation states it directly: the string never has a leading slash, so a pattern starting with ^/ never matches in per-directory context.
# Virtual host config: matches /old-page/
RewriteRule ^/old-page/$ /new-page/ [R=301,L]
# .htaccess in the document root: same redirect
RewriteRule ^old-page/$ /new-page/ [R=301,L]The pattern also never sees the host name or the query string. To test those, use a RewriteCond on %{HTTP_HOST} or %{QUERY_STRING}, and use %{REQUEST_URI} when you need the full path. Two server-side settings decide whether the rules run at all: rewriting in .htaccess needs Options FollowSymLinks or SymLinksIfOwnerMatch, and both RewriteRule and Redirect need AllowOverride FileInfo. The Apache .htaccess tutorial gives a quick test: put a nonsense word such as TestMe in the file and reload the page. If there is no 500 error, AllowOverride None is in effect and Apache ignores the file.
Difference between the L and END flags in .htaccess
In .htaccess, [L] does not mean “stop for good”. The flags documentation explains that in per-directory context [L] ends the current pass through the rules, but the rewritten request may be processed again from the top, which can cause loops. [END] stops all further rewrite processing for the request, and the documentation calls it the preferred flag for most per-directory rules. In server or virtual host context the two behave the same.
Three more details from the same page explain many broken redirects:
[R]without[L]passes the rewritten URL to the next rule, which can produce “Invalid URI in request” warnings, so write[R=301,L].- With a status outside 300 to 399, for example
[R=410], the substitution is dropped and the target URL is never sent.[G]and[F]express that intent more clearly. [QSD](Apache 2.4.0 and later) discards the query string, and when[QSD]and[QSA]are combined,[QSD]wins.
When Apache recommends Redirect instead of RewriteRule
The Apache team’s own guide, When not to use mod_rewrite, calls mod_rewrite a last resort and says simple redirects of one URL or a class of URLs should use Redirect or RedirectMatch. Redirect keeps path information, so Redirect "/one/" "http://one.example.com/" also moves /one/two.html. It matches only complete path segments: /service does not catch /servicefoo.txt. It passes GET parameters to the target, and POST bodies are discarded (mod_alias).
For a canonical host name the same guide uses an <If> block instead of RewriteCond:
<If "req('Host') != 'www.example.com'">
Redirect "/" "https://www.example.com/"
</If>It also notes that the front-controller pattern WordPress writes into .htaccess (route every non-existent file and directory to index.php) can be expressed as FallbackResource /index.php, which works in .htaccess too. In a WordPress install, leave the generated block alone, because WordPress rewrites everything between # BEGIN WordPress and # END WordPress. The practical rule is to keep one module per URL: do not let a Redirect and a RewriteRule both match the same path.
Where to store thousands of redirects instead of .htaccess
A long redirect list in .htaccess is paid for on every request. According to the .htaccess tutorial, when .htaccess files are allowed, httpd looks for them in every directory and in all parent directories, and reloads the file each time a document is requested, including images, CSS and JavaScript. The same page recommends the main server configuration whenever you have access to it.
Apache’s answer for large mappings is RewriteMap. It can only be declared in server or virtual host context, never in .htaccess (RewriteMap guide). A plain-text map holds one space-separated key and value per line, and httpd caches the looked-up keys until the file’s modification time changes. A dbm: map built with httxt2dbm is indexed, so lookups are faster than in a text file:
# Virtual host config, not .htaccess
RewriteEngine On
RewriteMap redirects "dbm:/etc/apache2/redirects.map"
RewriteCond "${redirects:%{REQUEST_URI}}" !=""
RewriteRule "^" "${redirects:%{REQUEST_URI}}" [R=301,L]# redirects.txt: one "old-path new-path" pair per line
# /old-page/ /new-page/
httxt2dbm -i redirects.txt -o redirects.mapThe condition skips the redirect when the path is not in the map, so unmapped URLs fall through to WordPress as usual.
Performance impact of .htaccess rules
What .htaccess costs on every request
The cost starts before the first rule runs. When AllowOverride allows .htaccess files, httpd looks for one in every directory and in all parent directories on every request, and loads the file each time a document is requested. The .htaccess tutorial gives an example: a request for a file in /www/htdocs/example costs four extra file-system lookups. Redirect chains add their own cost: Google’s site-move guide says chaining adds latency for users and asks for ideally no more than 3 hops and fewer than 5 (site moves with URL changes).
Rule order and regex for faster redirects
- Order Matters: Place most frequently matched rules first
- Use Specific Patterns: More specific patterns are faster
- Limit Regex Complexity: Avoid greedy quantifiers where possible
- Consider Server-Level Config: For high-traffic sites, use httpd.conf instead
# Optimized ordering example
RewriteEngine On
# Most common redirects first
RewriteRule ^wp-admin$ /wp-admin/ [R=301,L]
# Then specific patterns
RewriteRule ^old-product/(.*)$ /shop/$1 [R=301,L]
# Generic patterns last
RewriteRule ^blog/(.*)$ /news/$1 [R=301,L]Enable mod_expires and compression
# Enable mod_expires for better caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 month"
</IfModule>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript
</IfModule>WordPress .htaccess 301 redirect rules
Custom redirects before the WordPress block
WordPress uses its own rewrite rules in .htaccess. Always place custom rules before the WordPress block:
# BEGIN Custom Redirects
RewriteEngine On
# Your custom rules here
# END Custom Redirects
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPressRedirect attachment, author and category archives
# Redirect attachment pages to the homepage
RewriteEngine On
RewriteRule ^attachment/(.*)$ / [R=301,L]
# Redirect author archives to homepage
RewriteEngine On
RewriteRule ^author/(.*)$ / [R=301,L]
# Redirect category archives to specific page
RewriteEngine On
RewriteRule ^category/old-category$ /new-section/ [R=301,L]Protect wp-config.php and block malicious requests
Protect sensitive WordPress files
# Block access to sensitive WordPress files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# Protect wp-config.php
<Files wp-config.php>
Require all denied
</Files>
# Block access to .htaccess itself
<Files .htaccess>
Require all denied
</Files>Older guides use Order allow,deny and Deny from all. That is Apache 2.2 syntax: in 2.4 it only works through mod_access_compat, and the upgrade guide discourages it. Require all denied is the 2.4 form.
Block malicious query strings in .htaccess
# Block SQL injection attempts
RewriteCond %{QUERY_STRING} (<|%3C).*script.*(>|%3E) [NC,OR]
RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
RewriteRule ^(.*)$ index.php [F,L]Redirect vs RedirectMatch vs RewriteRule syntax
Redirect directive syntax
# Basic 301 redirect
Redirect 301 /old /new
# 302 temporary redirect
Redirect 302 /maintenance /temp-page
# Redirect with full URL
Redirect 301 /page http://example.com/new-pageRedirectMatch syntax
RedirectMatch (Apache mod_alias) works like Redirect, but takes a regular expression for the path:
# Pattern-based redirect
RedirectMatch 301 ^/blog/(.*)$ http://example.com/news/$1
# Case-insensitive
RedirectMatch 301 (?i)^/old-folder/(.*)$ /new-folder/$1RewriteRule syntax
# Basic structure
RewriteRule Pattern Substitution [Flags]
# Common flags:
# [R=301] - Permanent redirect
# [R=302] - Temporary redirect
# [L] - Last rule (stop processing)
# [NC] - No case (case-insensitive)
# [QSA] - Query string append
# [NE] - No escape.htaccess redirect checklist
Before implementing redirects:
- Test redirects with 302 before making them 301
- Verify no redirect loops exist
- Check mobile and desktop behavior
- Test with both www and non-www URLs
- Verify HTTPS enforcement if applicable
- Monitor Google Search Console for crawl errors
- Update internal links to point to new URLs
- Create a backup of your .htaccess file
Remember: a well-planned redirect map preserves your SEO rankings and prevents the bounce-rate spike that comes with users hitting a 404 during a migration or restructure.
.htaccess redirects on Nginx, logging and plugins
How to convert .htaccess redirects to Nginx
While .htaccess is Apache-specific, understanding how rules translate to NGINX is crucial for developers working across platforms:
Apache (.htaccess) to NGINX Conversion:
| Apache Directive | NGINX Equivalent |
|---|---|
Redirect 301 /old /new | return 301 /new; |
RewriteRule ^old$ /new [R=301,L] | location = /old { return 301 /new; } |
RewriteCond %{HTTPS} off | if ($scheme != 'https') |
NGINX server block example:
server {
listen 80;
server_name example.com;
# Permanent redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# WordPress rewrite rules
location / {
try_files $uri $uri/ /index.php?$args;
}
}Redirects by language, device, time and load balancer
1. Multilingual Site Redirects
# Redirect based on Accept-Language header
RewriteEngine On
RewriteCond %{HTTP:Accept-Language} ^de [NC]
RewriteRule ^$ /de/ [L,R=302]
RewriteCond %{HTTP:Accept-Language} ^fr [NC]
RewriteRule ^$ /fr/ [L,R=302]Google advises against redirecting users automatically to a language version based on what you think their language is, because it can stop users and search engines from reaching all versions of the site. Link to the other language versions instead (managing multi-regional sites).
2. Mobile Device Detection
# Detect mobile user agent
RewriteEngine On
RewriteCond %{HTTP_USER_AGENT} (android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge\ |maemo|midp|mmp|mobile.+firefox|netfront|opera\ m(ob|in)i|palm\ (os)?|phone|p(ixi|re)\\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows\ ce|xda|xiino [NC,OR]
RewriteCond %{HTTP_USER_AGENT} ^(1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a\ wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|l\ |nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1\ u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g\ |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/ [NC]
RewriteRule ^$ /mobile/ [L,R=302]3. Time-Based Redirects
# Redirect during maintenance window
RewriteEngine On
RewriteCond %{TIME_HOUR}%{TIME_MIN} >0800 [NC]
RewriteCond %{TIME_HOUR}%{TIME_MIN} <1700 [NC]
RewriteRule ^$ /maintenance/ [L,R=302]4. HTTP/HTTPS Detection for Load Balancers
# Handle X-Forwarded-Proto header from load balancers
RewriteEngine On
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]Debug .htaccess with rewrite logging
Enable detailed rewrite logging:
# Virtual host config, not .htaccess (Apache 2.4+)
LogLevel alert rewrite:trace3
# Log to file
ErrorLog ${APACHE_LOG_DIR}/rewrite.logCommon debugging commands:
# Check whether mod_rewrite is loaded
apache2ctl -M | grep rewrite
# Test the main server config (it does not read .htaccess)
apache2ctl configtest
# Enable mod_rewrite (Debian/Ubuntu), then reload Apache
a2enmod rewrite
# View Apache error logs
tail -f /var/log/apache2/error.logapache2ctl configtest checks only the main configuration. Apache reads .htaccess on each request (.htaccess tutorial), so an error in that file shows up as a 500 error when a page is requested.
Migration checklist for large sites
When migrating a large WordPress site with thousands of redirects:
Inventory Phase
- Export all URLs from Google Analytics
- Crawl existing site with Screaming Frog
- Export all indexed pages from Google Search Console
- Document all 404 errors
Planning Phase
- Map old URLs to new URLs
- Group redirects by pattern
- Prioritize high-traffic pages
- Plan redirect chains to minimize hops
Implementation Phase
- Test redirects on staging environment
- Implement in batches
- Monitor server response times
- Check for redirect loops
Verification Phase
- Use online redirect checkers
- Monitor Search Console for crawl errors
- Check server response times
- Check that the new URLs replace the old ones in search results
Cleanup Phase
- Remove temporary 302 redirects (convert to 301)
- Remove unnecessary rules
- Document all redirects for future reference
- Update XML sitemap with new URLs
WordPress plugin alternatives
For non-technical users or complex redirect management:
Redirection
- Free, widely used
- Supports regex patterns
- 404 logging
- Import/export functionality
Yoast SEO Premium
- Built-in redirect manager
- Automatic 404 monitoring
- Tight WordPress integration with no extra plugin glue
- Regex support
Safe Redirect Manager
- WordPress.org plugin
- Whitelist-based redirects
- HTTP status code selection
- Multisite compatible
Rank Math SEO
- Comprehensive SEO suite
- 301/302/307 redirects
- 404 monitoring
- 301 bulk import
Conclusion
Mastering .htaccess redirects is essential for any WordPress developer or site administrator. The key takeaways are:
- Always use 301 for permanent moves to preserve SEO value
- Test thoroughly before deployment using 302 redirects first
- Avoid redirect chains that create unnecessary hops
- Order rules logically with most specific patterns first
- Monitor performance as complex rules can impact page load times
- Document all redirects for future maintenance and troubleshooting
With the techniques and examples in this guide, you can confidently handle any redirect scenario, from simple page moves to complex multi-domain migrations.
Default WordPress .htaccess and custom rules
Default WordPress .htaccess file
WordPress generates a default .htaccess file when you enable pretty permalinks. Understanding its structure is crucial for adding custom rules:
# 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 WordPressWhere to add custom rules without breaking WordPress
When adding custom rules, always place them before the WordPress rules:
# BEGIN Custom Rules
Redirect 301 /old-page /new-page
# END Custom Rules
# 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 WordPressWordPress Multisite .htaccess rules
WordPress Multisite requires additional configuration:
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?wp-admin$ $1wp-admin/ [R=301,L]
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ $2 [L]
RewriteRule . index.php [L]Block directory browsing and restrict wp-admin by IP
Block wp-config.php, .htaccess and directory browsing
# Block access to wp-config.php
<Files wp-config.php>
Require all denied
</Files>
# Block access to .htaccess itself
<Files .htaccess>
Require all denied
</Files>
# Block directory browsing
Options -IndexesIP-based access control
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteCond %{REQUEST_URI} ^(.*)?wp-admin(.*)? [OR]
RewriteCond %{REQUEST_URI} ^(.*)?wp-login\.php(.*)?
RewriteCond %{REMOTE_ADDR} !^192\.168\.1\.100$
RewriteRule .* https://%{HTTP_HOST}/ [R=301,L]
</IfModule>Browser caching and GZIP rules in .htaccess
Browser caching headers
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType text/javascript "access plus 1 month"
</IfModule>
# GZIP Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript
</IfModule>Redirects for HTTPS, domain and URL structure migrations
HTTP to HTTPS migration
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]Domain migration
RewriteEngine On
RewriteCond %{HTTP_HOST} ^old-domain\.com$ [NC]
RewriteRule (.*)$ https://www.new-domain.com/$1 [R=301,L]URL structure changes
RewriteEngine On
RewriteRule ^blog/([0-9]{4})/([0-9]{2})/(.+)$ /articles/$3 [R=301,L]Mobile redirects, hotlink protection and forced downloads
Mobile redirects
RewriteCond %{HTTP_USER_AGENT} (android|bb\d+|meego)|mobile|up\.browser [NC]
RewriteRule ^(.*)$ https://mobile.example.com/$1 [R=301,L]Hotlink protection
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https://(www\.)?yourdomain\.com [NC]
RewriteRule \.(jpg|jpeg|png|gif|webp)$ - [F]The !^$ condition lets requests with no Referer through, so direct visits and browsers that suppress the header are not blocked. The Referer header is optional and can be spoofed (When not to use mod_rewrite), so this rule cannot serve as access control.
Force file downloads
<FilesMatch "\.pdf$">
Header set Content-Disposition attachment
</FilesMatch>Explore our WordPress speed optimization to take your project further.






