Common Routing Patterns in .htaccess
Every web project eventually needs URL routing beyond simple page-to-page redirects. Whether you are enforcing a canonical domain, hiding file extensions for cleaner URLs, routing everything through a single entry point, or putting up a maintenance page during deployments, .htaccess provides battle-tested patterns for all of these scenarios.
This guide collects the most common routing patterns you will encounter in real-world projects. Each pattern is presented with a clear explanation, ready-to-use code, and notes on common mistakes. If you have already read through the fundamentals of redirects and URL rewrites, this is where you put that knowledge into practice.
WWW vs Non-WWW Canonicalization​
Search engines treat www.example.com and example.com as two separate sites. If both versions serve the same content, you are creating duplicate content, which dilutes your SEO ranking and confuses crawlers. You must pick one version and redirect the other to it.
Redirecting WWW to Non-WWW​
This is the most popular choice for modern websites. It produces shorter, cleaner URLs.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
</IfModule>
How it works:
RewriteCond %{HTTP_HOST} ^www\.(.+)$checks if the hostname starts withwww.and captures everything after it into%1.RewriteRule ^(.*)$matches any URL path and captures it into$1.- The substitution
https://%1/$1builds the new URL using the domain withoutwww.and the original path.
| Request | Redirects To |
|---|---|
http://www.example.com/about | https://example.com/about |
https://www.example.com/blog/post | https://example.com/blog/post |
https://example.com/about | No redirect (already correct) |
Redirecting Non-WWW to WWW​
Some organizations and larger sites prefer the www version. The www subdomain also offers more flexibility with DNS (for example, you can point a CNAME record at a CDN).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteCond %{HTTP_HOST} !^localhost$ [NC]
RewriteCond %{SERVER_ADDR} !=127.0.0.1
RewriteCond %{SERVER_ADDR} !=::1
RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]
</IfModule>
The additional conditions for localhost, 127.0.0.1, and ::1 prevent the rule from firing during local development. Without them, your local environment would try to redirect to www.localhost, which does not exist.
Always use 301 (permanent) redirects for canonicalization. A 302 redirect tells search engines the original URL might come back, so they will not transfer ranking signals to the canonical version. Using 301 ensures search engines consolidate all authority on your chosen URL.
Common Mistake: Protocol-Unaware Canonicalization​
Wrong approach:
# This redirects to https:// even if you don't have SSL configured
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
If your site does not have an SSL certificate, hardcoding https:// will cause a connection error. If you need to support both HTTP and HTTPS dynamically, detect the protocol first:
Correct approach (protocol-aware):
<IfModule mod_rewrite.c>
RewriteEngine On
# Detect the current protocol
RewriteCond %{HTTPS} =on
RewriteRule ^ - [E=PROTO:https]
RewriteCond %{HTTPS} !=on
RewriteRule ^ - [E=PROTO:http]
# Redirect www to non-www using the detected protocol
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ %{ENV:PROTO}://%1/$1 [R=301,L]
</IfModule>
The E=PROTO flag sets an environment variable that stores the current protocol, which is then used in the substitution. This way, HTTP requests stay on HTTP and HTTPS requests stay on HTTPS.
Domain Canonicalization​
Sometimes you need to consolidate traffic from multiple domains onto a single primary domain. This is common after rebranding, acquiring competitor domains, or merging several sites into one.
Redirecting Multiple Domains to One​
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect old domains to primary domain
RewriteCond %{HTTP_HOST} ^(www\.)?old-brand\.com$ [NC,OR]
RewriteCond %{HTTP_HOST} ^(www\.)?another-domain\.net$ [NC,OR]
RewriteCond %{HTTP_HOST} ^(www\.)?typo-domain\.com$ [NC]
RewriteRule ^(.*)$ https://primary-domain.com/$1 [R=301,L]
</IfModule>
Each RewriteCond checks for one of the old domains. The [OR] flag means any one of them being true is enough for the rule to fire. The (www\.)? part makes each condition match both the www and non-www versions.
| Request | Redirects To |
|---|---|
http://old-brand.com/products | https://primary-domain.com/products |
https://www.another-domain.net/about | https://primary-domain.com/about |
https://primary-domain.com/about | No redirect (already correct) |
Redirecting a Specific Path on a Different Domain​
In some cases, only certain paths from an old domain need to be redirected to specific pages on the new domain:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?old-brand\.com$ [NC]
RewriteRule ^support/?$ https://primary-domain.com/help-center [R=301,L]
RewriteCond %{HTTP_HOST} ^(www\.)?old-brand\.com$ [NC]
RewriteRule ^(.*)$ https://primary-domain.com/$1 [R=301,L]
</IfModule>
Place more specific rules before general ones. In the example above, the /support rule must come before the catch-all rule, otherwise it would never be reached.
Clean URLs (Removing File Extensions)​
Clean URLs without .html or .php extensions look more professional, are easier to remember, and are generally better for SEO. Instead of example.com/about.html, users see example.com/about.
Removing the .html Extension​
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect requests with .html to the clean version (external redirect)
RewriteCond %{REQUEST_URI} \.html$ [NC]
RewriteRule ^(.+)\.html$ /$1 [R=301,L]
# Internally rewrite clean URLs to the .html file
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
</IfModule>
This pattern works in two stages:
- External redirect: If someone visits
/about.html(or a search engine has the old URL indexed), they are 301-redirected to/about. This ensures only one URL exists for each page. - Internal rewrite: When someone visits
/about, Apache checks ifabout.htmlexists on disk. If it does, the request is internally rewritten to serve that file.
| Request URL | What Happens |
|---|---|
/about.html | 301 redirect to /about |
/about | Internally serves /about.html (if file exists) |
/contact.html | 301 redirect to /contact |
/contact | Internally serves /contact.html (if file exists) |
/style.css | Served normally (no rewrite) |
Removing the .php Extension​
The same pattern applies to PHP files:
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect requests with .php to the clean version
RewriteCond %{REQUEST_URI} \.php$ [NC]
RewriteCond %{REQUEST_URI} !^/index\.php$ [NC]
RewriteRule ^(.+)\.php$ /$1 [R=301,L]
# Internally rewrite clean URLs to the .php file
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
</IfModule>
Notice the additional condition !^/index\.php$ that excludes index.php. This is important if you are using a front controller pattern (discussed in the next section) where index.php is the main entry point. Without this exclusion, you could create a redirect loop.
Common Mistake: Forgetting the File Existence Check​
Wrong approach:
# This blindly appends .html to EVERY request
RewriteRule ^(.+)$ $1.html [L]
A request to /images/logo.png would be rewritten to /images/logo.png.html, which obviously does not exist and returns a 404 error.
Correct approach:
# Only rewrite if the .html file actually exists
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
The condition %{REQUEST_FILENAME}.html -f checks that the file with .html appended actually exists before performing the rewrite. Assets like images, CSS, and JavaScript files pass through untouched.
Front Controller Pattern​
The front controller pattern routes all requests through a single entry point, typically index.php. This is the foundation of virtually every modern PHP framework, including WordPress, Laravel, Symfony, Slim, and many others.
Basic Front Controller​
<IfModule mod_rewrite.c>
RewriteEngine On
# If the requested file exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-f
# If the requested directory exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-d
# Otherwise, route through index.php
RewriteRule ^(.*)$ /index.php [L]
</IfModule>
This is deceptively simple but extremely important. Here is what happens for different requests:
| Request URL | File Exists? | What Happens |
|---|---|---|
/style.css | Yes | Served directly (conditions fail) |
/images/logo.png | Yes | Served directly (conditions fail) |
/about | No | Rewritten to /index.php |
/users/42/profile | No | Rewritten to /index.php |
The PHP application in index.php then reads $_SERVER['REQUEST_URI'] to determine which page or controller to load.
Front Controller with Query String Passthrough​
Some frameworks expect the original URL to be passed as a query parameter:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php?url=$1 [QSA,L]
</IfModule>
The QSA flag ensures that any existing query string parameters are appended. So a request to /products/search?category=electronics becomes /index.php?url=products/search&category=electronics.
Excluding Specific Directories​
Sometimes you need to exclude certain directories from the front controller, for example, an admin panel or an API that has its own routing:
<IfModule mod_rewrite.c>
RewriteEngine On
# Skip rewriting for these directories
RewriteRule ^(admin|api|assets)/ - [L]
# Front controller for everything else
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
</IfModule>
The first rule matches paths starting with admin/, api/, or assets/ and uses - as the substitution (meaning "do nothing"), with the [L] flag to stop processing. These requests are passed through to Apache's normal file-serving behavior.
When using a front controller, make sure your application handles 404 errors internally. Since Apache is routing all non-file requests to index.php, it will always return a 200 status code unless your PHP code explicitly sets a 404 header for unknown routes.
Language-Based Routing​
Serving content in different languages based on the user's preferences is a common requirement for international sites. You can route users based on URL prefixes, the Accept-Language header, or cookies.
URL Prefix-Based Language Routing​
The most SEO-friendly approach is to use language prefixes in URLs (/en/about, /fr/about, /de/about):
<IfModule mod_rewrite.c>
RewriteEngine On
# Route language-prefixed URLs to the appropriate directory or handler
RewriteRule ^(en|fr|de|es)/(.*)$ /content/$1/$2 [L]
# Default to English if no language prefix is present
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /content/en/$1 [L]
</IfModule>
| Request URL | Internally Served From |
|---|---|
/en/about | /content/en/about |
/fr/contact | /content/fr/contact |
/about | /content/en/about |
Auto-Detecting Language from Browser Headers​
You can read the browser's Accept-Language header and redirect first-time visitors to the appropriate language version:
<IfModule mod_rewrite.c>
RewriteEngine On
# Only redirect the homepage, not subpages
# Only redirect if no language prefix is already present
RewriteCond %{REQUEST_URI} ^/$ [NC]
# Check if the browser prefers French
RewriteCond %{HTTP:Accept-Language} ^fr [NC]
RewriteRule ^$ /fr/ [R=302,L]
# Check if the browser prefers German
RewriteCond %{REQUEST_URI} ^/$ [NC]
RewriteCond %{HTTP:Accept-Language} ^de [NC]
RewriteRule ^$ /de/ [R=302,L]
# Default to English
RewriteCond %{REQUEST_URI} ^/$ [NC]
RewriteRule ^$ /en/ [R=302,L]
</IfModule>
Use 302 (temporary) redirects for language auto-detection, never 301. A permanent redirect would be cached by the browser, and if a French-speaking user later switches their browser language to English, they would still be sent to the French version. A 302 ensures the detection happens fresh each time.
Cookie-Based Language Persistence​
After a user manually selects a language, you can store their preference in a cookie and use it for future visits:
<IfModule mod_rewrite.c>
RewriteEngine On
# If user selects a language via ?lang=xx, set a cookie and redirect
RewriteCond %{QUERY_STRING} lang=(en|fr|de|es)
RewriteRule ^(.*)$ /$1? [CO=user_lang:%1:.example.com:86400:/,R=302,L]
# If a language cookie exists and user is on the homepage, redirect
RewriteCond %{REQUEST_URI} ^/$ [NC]
RewriteCond %{HTTP_COOKIE} user_lang=(en|fr|de|es)
RewriteRule ^$ /%1/ [R=302,L]
</IfModule>
The CO flag sets a cookie named user_lang with the matched language code, valid for 86400 seconds (one day), scoped to .example.com.
Query String-Based Rewrites​
Sometimes you need to rewrite or redirect based on query string parameters. This is common when migrating from an old CMS that used query parameters for routing to a new system with clean URLs.
Redirecting Query String URLs to Clean URLs​
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect /index.php?page=about to /about
RewriteCond %{QUERY_STRING} ^page=([a-zA-Z0-9_-]+)$ [NC]
RewriteRule ^index\.php$ /%1? [R=301,L]
</IfModule>
Key details:
%{QUERY_STRING}does not include the leading?.- The capture group in the condition (
([a-zA-Z0-9_-]+)) is referenced as%1in the substitution. - The trailing
?in the substitution strips the original query string. Without it, the redirect would keep the query string and you would end up with/about?page=about.
| Request URL | Redirects To |
|---|---|
/index.php?page=about | /about |
/index.php?page=contact | /contact |
/index.php | No redirect |
Rewriting with Multiple Query Parameters​
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect /products.php?category=shoes&id=42 to /products/shoes/42
RewriteCond %{QUERY_STRING} ^category=([^&]+)&id=([0-9]+)$ [NC]
RewriteRule ^products\.php$ /products/%1/%2? [R=301,L]
</IfModule>
%1captures the category value.%2captures the id value.- The trailing
?strips the old query string from the redirected URL.
Common Mistake: Forgetting to Strip the Query String​
Wrong approach:
# Missing trailing ? in the substitution
RewriteCond %{QUERY_STRING} ^page=([a-zA-Z0-9_-]+)$
RewriteRule ^index\.php$ /%1 [R=301,L]
A request to /index.php?page=about redirects to /about?page=about because Apache carries the original query string by default.
Correct approach:
# Trailing ? removes the original query string
RewriteCond %{QUERY_STRING} ^page=([a-zA-Z0-9_-]+)$
RewriteRule ^index\.php$ /%1? [R=301,L]
Now /index.php?page=about correctly redirects to /about with no query string.
Remember: an empty ? at the end of a substitution URL is not the same as having no ?. The empty ? explicitly tells Apache to discard the original query string. Without it, the original query string is always preserved.
Maintenance Mode Page​
During deployments, migrations, or emergency fixes, you may need to temporarily show a maintenance page to all visitors while allowing developers or specific IPs to access the live site.
Basic Maintenance Mode​
<IfModule mod_rewrite.c>
RewriteEngine On
# Serve maintenance page for everyone
RewriteCond %{REQUEST_URI} !^/maintenance\.html$ [NC]
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
RewriteRule ^(.*)$ /maintenance.html [R=503,L]
</IfModule>
# Set the Retry-After header for search engines
ErrorDocument 503 /maintenance.html
Header Set Retry-After "3600"
Important details:
- The first condition
!^/maintenance\.html$prevents an infinite redirect loop. Without it, the maintenance page itself would be redirected. - The second condition
!^/assets/allows CSS, JavaScript, and images in the/assets/directory to load so the maintenance page looks presentable. - The
R=503flag returns a 503 Service Unavailable status, which tells search engines the downtime is temporary and they should come back later. - The
Retry-Afterheader suggests to crawlers how long to wait before retrying (in seconds).
Never use a 301 or 302 redirect for maintenance pages. A 301 would tell search engines your entire site has permanently moved to the maintenance page. A 302 is better but still not ideal. The correct status code is 503 Service Unavailable, which explicitly signals temporary downtime.
Maintenance Mode with IP Whitelist​
Allow specific IP addresses (your office, VPN, or home connection) to bypass the maintenance page:
<IfModule mod_rewrite.c>
RewriteEngine On
# Allow specific IPs to bypass maintenance
RewriteCond %{REMOTE_ADDR} !^192\.168\.1\.100$
RewriteCond %{REMOTE_ADDR} !^10\.0\.0\.50$
# Don't redirect the maintenance page or its assets
RewriteCond %{REQUEST_URI} !^/maintenance\.html$ [NC]
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
# Send everyone else to maintenance
RewriteRule ^(.*)$ /maintenance.html [R=503,L]
</IfModule>
ErrorDocument 503 /maintenance.html
Header Set Retry-After "3600"
All conditions are joined with the default AND logic. So the rule fires only when:
- The visitor's IP is NOT
192.168.1.100AND - The visitor's IP is NOT
10.0.0.50AND - The request is NOT for the maintenance page itself AND
- The request is NOT for assets.
Developers coming from the whitelisted IPs see the live site normally.
Maintenance Mode with Cookie-Based Bypass​
An alternative approach uses a secret URL parameter to set a bypass cookie. This is useful when developers need access from varying IP addresses:
<IfModule mod_rewrite.c>
RewriteEngine On
# Set bypass cookie when ?bypass=secretkey is present
RewriteCond %{QUERY_STRING} bypass=secretkey123
RewriteRule ^(.*)$ /$1? [CO=maintenance_bypass:1:.example.com:3600:/,L]
# Skip maintenance for users with the bypass cookie
RewriteCond %{HTTP_COOKIE} maintenance_bypass=1
RewriteRule ^ - [L]
# Don't redirect maintenance page assets
RewriteCond %{REQUEST_URI} !^/maintenance\.html$ [NC]
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
# Everyone else gets maintenance page
RewriteRule ^(.*)$ /maintenance.html [R=503,L]
</IfModule>
ErrorDocument 503 /maintenance.html
Header Set Retry-After "3600"
A developer visits https://example.com/?bypass=secretkey123, receives a cookie, and can then browse the site normally for one hour. Everyone else sees the maintenance page.
Common Mistake: Maintenance Redirect Loop​
Wrong approach:
# Missing exclusion for the maintenance page itself
RewriteRule ^(.*)$ /maintenance.html [R=503,L]
This creates an infinite loop: the request for /maintenance.html itself matches ^(.*)$ and gets redirected to /maintenance.html again, which matches again, and so on until the server gives up with a 500 error.
Correct approach:
# Exclude the maintenance page from the rewrite
RewriteCond %{REQUEST_URI} !^/maintenance\.html$ [NC]
RewriteRule ^(.*)$ /maintenance.html [R=503,L]
Always exclude the maintenance page URL and any assets it depends on from the rewrite rule.
Quick Reference​
Here is a summary of all routing patterns covered in this guide:
<IfModule mod_rewrite.c>
RewriteEngine On
# --- WWW Canonicalization (www to non-www) ---
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
# --- Domain Canonicalization ---
RewriteCond %{HTTP_HOST} ^(www\.)?old-domain\.com$ [NC]
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]
# --- Clean URLs (remove .php) ---
RewriteCond %{REQUEST_URI} \.php$ [NC]
RewriteRule ^(.+)\.php$ /$1 [R=301,L]
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
# --- Query String Redirect ---
RewriteCond %{QUERY_STRING} ^page=([a-zA-Z0-9_-]+)$
RewriteRule ^index\.php$ /%1? [R=301,L]
# --- Front Controller ---
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
</IfModule>
Each pattern in this guide addresses a real-world problem you will encounter when building and maintaining websites. Start with the patterns you need immediately, test them thoroughly in a staging environment, and keep this reference handy for when new requirements arise. Correct routing is not just a technical detail; it directly impacts your site's user experience, SEO performance, and overall reliability.