.htaccess URL Rewrites
URL rewriting is one of the most powerful features Apache offers through its mod_rewrite module. It allows you to transform how URLs look and behave without changing your underlying file structure. While redirects tell the browser to go somewhere else, rewrites silently map one URL to another on the server side.
This guide covers everything you need to understand URL rewriting: what it is, how it differs from redirecting, the anatomy of rewrite rules and conditions, how to use flags and backreferences, and the order in which Apache processes your rules. By the end, you will be comfortable reading, writing, and debugging rewrite rules in .htaccess.
What is .URL Rewriting?
URL rewriting is the process of internally translating a requested URL into a different URL on the server, without the client ever knowing. The browser still sees the original URL in the address bar, but Apache serves content from a different location or script behind the scenes.
For example, when a user visits:
https://example.com/products/laptop
Apache can internally rewrite this to:
https://example.com/store.php?category=products&item=laptop
The user sees the clean, readable URL. The server processes the request through store.php with query parameters. Everyone wins.
Common use cases for URL rewriting include:
- Clean URLs: Turning
/index.php?page=aboutinto/about. - Front controller pattern: Routing all requests through a single entry point (like
index.phpin frameworks such as Laravel or WordPress). - API versioning: Mapping
/api/v2/usersto an internal script. - Content negotiation: Serving different resources based on request characteristics.
Rewrite vs Redirect
Understanding the difference between rewriting and redirecting is essential before diving into the syntax. These two concepts are often confused, but they serve very different purposes.
| Aspect | Rewrite | Redirect |
|---|---|---|
| Visibility | Internal, the browser URL does not change | External, the browser navigates to a new URL |
| HTTP roundtrips | One (the server handles it internally) | Two (the server tells the browser to re-request) |
| Browser awareness | The browser has no idea a rewrite happened | The browser sees the new URL |
| SEO impact | No impact, content appears to live at the original URL | Transfers or splits SEO value depending on status code |
| Use case | Clean URLs, routing to scripts | Moved pages, protocol changes, domain changes |
Here is a practical comparison:
# REWRITE: User sees /about, server processes /pages/about.html
RewriteRule ^about$ /pages/about.html [L]
# REDIRECT: User sees /new-about in the browser, server sends 301
RewriteRule ^old-about$ /new-about [R=301,L]
A simple way to remember: if you want the user to see the new URL, use a redirect. If you want to hide the real URL from the user, use a rewrite.
Enabling the Rewrite Engine
Before any rewrite rule can work, you must explicitly enable the rewrite engine. This is a required step that is easy to forget and is the number one reason rewrite rules silently fail.
<IfModule mod_rewrite.c>
RewriteEngine On
Options +FollowSymlinks
</IfModule>
Let's break down each part:
<IfModule mod_rewrite.c>: Wraps the block so it only executes ifmod_rewriteis loaded. This prevents a 500 Internal Server Error if the module is not available.RewriteEngine On: Activates the rewrite engine. Without this line, allRewriteRuleandRewriteConddirectives are ignored.Options +FollowSymlinks: Required formod_rewriteto function correctly. It allows Apache to follow symbolic links in the directory.
Hosting-Specific Considerations
Depending on your hosting environment, you may need additional configuration:
<IfModule mod_rewrite.c>
RewriteEngine On
Options +FollowSymlinks
# If your host doesn't allow FollowSymlinks, use this instead:
# Options +SymLinksIfOwnerMatch
# Some shared/cloud hosts require a RewriteBase:
# RewriteBase /
# Advanced rewrite options (rarely needed):
# RewriteOptions <options>
</IfModule>
If your hosting provider does not allow FollowSymlinks, switch to SymLinksIfOwnerMatch. However, be aware this can have a performance impact because Apache must perform additional checks to verify file ownership on every request.
RewriteRule Anatomy
The RewriteRule directive is where the actual URL transformation happens. Its syntax has three parts:
RewriteRule Pattern Substitution [Flags]
Let's examine each component in detail.
Pattern
The pattern is a regular expression that is tested against the URL path of the incoming request. In .htaccess context, the path is relative to the directory containing the .htaccess file, and it does not include a leading slash.
# Matches the URL /about (note: no leading slash in the pattern)
RewriteRule ^about$ /pages/about.html [L]
Important details about the pattern:
- It is a Perl-compatible regular expression (PCRE).
- In
.htaccess, the tested path has no leading slash. The URL/products/itemis tested asproducts/item. - The pattern
^$matches the root URL (/). - A pattern of
^(.*)$matches everything.
# Match the root URL
RewriteRule ^$ /home.php [L]
# Match anything
RewriteRule ^(.*)$ /index.php [L]
# Match URLs starting with "blog/"
RewriteRule ^blog/(.*)$ /blog.php?slug=$1 [L]
Substitution
The substitution is the string that replaces the original URL when the pattern matches. It can be:
- A file path relative to the document root.
- An absolute URL (which automatically triggers a redirect).
- A single hyphen (
-) meaning "do not substitute" (useful when you only want to set flags or environment variables).
# Internal rewrite to a PHP file
RewriteRule ^user/([a-z]+)$ /profile.php?username=$1 [L]
# External redirect (absolute URL triggers redirect automatically)
RewriteRule ^old-site/(.*)$ https://new-site.com/$1 [R=301,L]
# No substitution, just set an environment variable
RewriteRule ^secure/ - [E=REQUIRE_AUTH:1]
Flags
Flags are enclosed in square brackets [...] and modify the behavior of the rewrite rule. Multiple flags are separated by commas. These are the most important ones you will use regularly.
L (Last)
The L flag tells Apache to stop processing further rewrite rules if this rule matches. Without it, Apache continues evaluating subsequent rules, which can lead to unexpected behavior.
RewriteRule ^about$ /pages/about.html [L]
RewriteRule ^(.*)$ /index.php [L]
Without [L] on the first rule, a request to /about would first be rewritten to /pages/about.html, and then the second rule would rewrite it again to /index.php. The L flag prevents this cascade.
In .htaccess context, L does not completely stop all processing. Apache re-runs the entire .htaccess ruleset on the rewritten URL. This means the rules execute again with the new URL as input. The [END] flag (Apache 2.3.9+) fully stops all further rewriting.
R (Redirect)
The R flag forces an external redirect instead of an internal rewrite. You can optionally specify the HTTP status code.
# 302 temporary redirect (default when no code is specified)
RewriteRule ^old$ /new [R]
# 301 permanent redirect
RewriteRule ^old$ /new [R=301]
# Combine with L to stop processing
RewriteRule ^old$ /new [R=301,L]
When would you use R instead of a plain Redirect directive? When you need the power of regex matching or conditions (RewriteCond) to control when the redirect happens.
NC (NoCase)
The NC flag makes the pattern match case-insensitive.
# Matches /About, /ABOUT, /about, /aBoUt, etc.
RewriteRule ^about$ /pages/about.html [NC,L]
Without NC, the pattern ^about$ only matches the exact lowercase string about.
QSA (Query String Append)
The QSA flag appends the original query string to the substitution URL instead of discarding it.
Without QSA:
RewriteRule ^search/(.+)$ /search.php?query=$1 [L]
A request to /search/laptops?page=2 becomes /search.php?query=laptops. The ?page=2 is lost.
With QSA:
RewriteRule ^search/(.+)$ /search.php?query=$1 [QSA,L]
A request to /search/laptops?page=2 becomes /search.php?query=laptops&page=2. The original query string is preserved and appended.
Other Common Flags
| Flag | Name | Description |
|---|---|---|
END | End | Fully stops rewriting (unlike L, it prevents re-processing in .htaccess). Apache 2.3.9+. |
F | Forbidden | Returns a 403 Forbidden response immediately. |
G | Gone | Returns a 410 Gone response immediately. |
NE | No Escape | Prevents special characters in the substitution from being hex-encoded. |
P | Proxy | Forces the substitution to be handled as a proxy request (requires mod_proxy). |
S=N | Skip | Skips the next N rules if this rule matches. |
E=VAR:VAL | Environment Variable | Sets an environment variable VAR to the value VAL. |
CO | Cookie | Sets a cookie in the response. |
Example combining multiple flags:
# Block access to hidden files, return 403
RewriteRule (^\.|/\.) - [F,L]
# Proxy a request to a backend (requires mod_proxy)
RewriteRule ^api/(.*)$ http://backend-server:8080/$1 [P,L]
RewriteCond Anatomy
A RewriteCond directive defines a condition that must be true for the immediately following RewriteRule to be applied. Without conditions, a rewrite rule runs on every single request that matches its pattern. Conditions give you precise control.
The syntax is:
RewriteCond TestString ConditionPattern [Flags]
Test String
The test string is the value you want to test. It typically contains server variables enclosed in %{VARIABLE_NAME} syntax. It can also include backreferences and literal text.
# Test the HTTPS variable
RewriteCond %{HTTPS} off
Condition Pattern
The condition pattern is what the test string is compared against. It can be:
- A regular expression (default behavior).
- A string comparison using lexicographic operators like
<,>,=. - A special test prefixed with
-(such as-ffor "is a file",-dfor "is a directory").
# Regex: check if HTTPS is off
RewriteCond %{HTTPS} off
# String comparison: exact match
RewriteCond %{HTTP_HOST} =www.example.com
# Special test: check if the requested path is NOT a file
RewriteCond %{REQUEST_FILENAME} !-f
# Special test: check if the requested path is NOT a directory
RewriteCond %{REQUEST_FILENAME} !-d
The ! prefix negates the condition. So !-f means "is NOT an existing file."
Server Variables
Server variables give you access to virtually every aspect of the incoming request. Here are the most commonly used ones:
REQUEST_URI
The full path component of the requested URL, including the leading slash.
# Only apply the rule if the URL starts with /api/
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^(.*)$ /api-handler.php [L]
HTTP_HOST
The hostname from the Host: header of the request (e.g., www.example.com).
# Redirect www to non-www
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1%{REQUEST_URI} [R=301,L]
HTTPS
Contains on if the connection uses SSL/TLS, or off otherwise.
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Behind a reverse proxy or load balancer, %{HTTPS} may always report off because the proxy connects to Apache over HTTP. In those cases, check %{HTTP:X-Forwarded-Proto} instead.
REQUEST_FILENAME
The full local filesystem path to the file or directory that matches the request. This is extremely useful for the front controller pattern.
# If the request is not an existing file or directory, route to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
This is the classic rewrite pattern used by WordPress, Laravel, Symfony, and virtually every PHP framework.
QUERY_STRING
The query string portion of the URL (everything after ?), without the leading ?.
# Redirect if a specific query parameter exists
RewriteCond %{QUERY_STRING} ^id=([0-9]+)$
RewriteRule ^product\.php$ /products/%1? [R=301,L]
The trailing ? in the substitution removes the original query string. Without it, the query string would be carried over.
Combining Multiple Conditions
When you stack multiple RewriteCond directives before a RewriteRule, they are combined with an implicit AND logic. All conditions must be true for the rule to fire.
# Both conditions must be true (AND logic, default)
RewriteCond %{HTTPS} off
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^(.*)$ https://example.com/$1 [R=301,L]
This rule only fires if the request is not HTTPS AND the host starts with www. If only one condition is true, the rule is skipped.
To use OR logic instead, add the [OR] flag to the conditions:
# Either condition can be true (OR logic)
RewriteCond %{HTTP_HOST} ^old-domain\.com$ [NC,OR]
RewriteCond %{HTTP_HOST} ^legacy-domain\.com$ [NC]
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]
This redirects traffic from either old-domain.com or legacy-domain.com to new-domain.com.
You can mix AND and OR logic:
# (Condition A OR Condition B) AND Condition C
RewriteCond %{HTTP_HOST} ^old-domain\.com$ [NC,OR]
RewriteCond %{HTTP_HOST} ^legacy-domain\.com$ [NC]
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]
Be careful with OR logic. The [OR] flag only applies between the two conditions it connects. The grouping can be tricky. In the example above, the logic is: (Host is old-domain OR Host is legacy-domain) AND HTTPS is off. The last condition without [OR] starts a new AND group.
Backreferences
Backreferences let you capture parts of matched strings and reuse them in the substitution or in subsequent conditions. There are two types, and confusing them is a very common source of bugs.
Rule Backreferences ($1, $2, ...)
When the pattern of a RewriteRule contains parenthesized groups, the captured values are available as $1, $2, $3, and so on, in the substitution string.
# Pattern: ^blog/([0-9]{4})/([a-z-]+)$
# $1 = the year (e.g., "2024")
# $2 = the slug (e.g., "my-post")
RewriteRule ^blog/([0-9]{4})/([a-z-]+)$ /blog.php?year=$1&slug=$2 [L]
A request to /blog/2024/my-post is internally rewritten to /blog.php?year=2024&slug=my-post.
Condition Backreferences (%1, %2, ...)
When a RewriteCond pattern contains parenthesized groups, the captured values are available as %1, %2, %3, and so on. These can be used in the substitution of the following RewriteRule or in subsequent RewriteCond test strings.
# %1 captures the domain without "www."
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
Here:
%1comes from theRewriteCondcapture group(.+)and contains the domain withoutwww.(e.g.,example.com).$1comes from theRewriteRulecapture group(.*)and contains the URL path.
Common Mistake: Mixing Up $ and %
Wrong approach:
# BUG: Using $1 to reference a RewriteCond capture group
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://$1/$1 [R=301,L]
If someone visits www.example.com/about, you might expect https://example.com/about, but instead you get https://about/about because $1 refers to the RewriteRule capture (which is about), not the RewriteCond capture.
Correct approach:
# Use %1 for RewriteCond captures, $1 for RewriteRule captures
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
Quick reference:
| Syntax | Source | Example |
|---|---|---|
$1 | RewriteRule pattern | Captured from the URL path |
%1 | RewriteCond pattern | Captured from a condition |
Rewrite Order of Processing
Understanding the order in which Apache processes rewrite rules is critical for debugging and writing correct configurations. Here is how it works in .htaccess:
Step-by-Step Processing
- Apache receives a request and determines which
.htaccessfile applies. RewriteEngine Onmust be present, or all rules are skipped.- Rules are processed sequentially from top to bottom in the order they appear.
- For each
RewriteRule, Apache first evaluates all precedingRewriteConddirectives (if any). If the conditions pass, the rule's pattern is tested. - If the pattern matches, the substitution is applied.
- If the
[L]flag is present, Apache stops processing further rules in this pass. - However, in
.htaccesscontext, the rewritten URL is then re-injected into the URL mapping pipeline, and the entire.htaccessruleset runs again from the top with the new URL. This loop continues until no rule matches or the URL stabilizes.
Practical Implications
This re-processing behavior is the most common source of confusion and infinite loops in .htaccess rewrite rules. Consider this example:
Wrong approach:
# This creates an infinite loop!
RewriteRule ^(.*)$ /index.php [L]
On the first pass, /about is rewritten to /index.php. Then the rules run again, and /index.php matches ^(.*)$, rewriting to /index.php again. Apache detects the loop after about 10 iterations and returns a 500 Internal Server Error.
Correct approach:
# Only rewrite if the request is not already for an existing file or directory
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
Now, on the second pass, /index.php is an existing file, so !-f fails and the rule is skipped. The loop is broken.
Rule Ordering Best Practices
The order of your rules matters. Follow these guidelines:
<IfModule mod_rewrite.c>
RewriteEngine On
# 1. Protocol and domain rules first (HTTPS, www)
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# 2. Specific redirects for moved pages
RewriteRule ^old-page$ /new-page [R=301,L]
RewriteRule ^legacy/(.*)$ /modern/$1 [R=301,L]
# 3. Access control rules
RewriteRule (^\.|/\.) - [F,L]
# 4. Front controller / catch-all rule LAST
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
</IfModule>
Always place more specific rules before general ones. The catch-all front controller pattern (^(.*)$) should always be the last rule because it matches everything. Any rules placed after it will never be reached.
Debugging Tip
If your rules are not behaving as expected, you can enable the rewrite log (in the server config, not .htaccess) to see exactly what Apache is doing:
# In httpd.conf or virtual host config (Apache 2.4+)
LogLevel alert rewrite:trace6
This generates detailed log entries showing which rules and conditions are being evaluated, what they match, and what the substitution result is. Set the trace level from trace1 (minimal) to trace8 (extremely verbose).
Never enable rewrite tracing in production. It generates an enormous amount of log data and significantly impacts server performance. Use it only in development or staging environments for debugging.
Putting It All Together
Here is a complete, well-structured .htaccess file that demonstrates the concepts covered in this guide:
<IfModule mod_rewrite.c>
RewriteEngine On
Options +FollowSymlinks
# === Protocol: Force HTTPS ===
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# === Domain: Redirect www to non-www ===
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1%{REQUEST_URI} [R=301,L]
# === Specific page redirects ===
RewriteRule ^old-about$ /about [R=301,L]
RewriteRule ^blog/([0-9]{4})/([0-9]{2})/(.+)$ /articles/$3 [R=301,L]
# === Clean URLs: Map /user/john to profile.php?name=john ===
RewriteRule ^user/([a-zA-Z0-9_-]+)$ /profile.php?name=$1 [QSA,L]
# === Security: Block access to hidden files ===
RewriteRule (^\.|/\.) - [F,L]
# === Front Controller: Route everything else to index.php ===
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]
</IfModule>
This file follows the recommended order: protocol enforcement first, then domain canonicalization, then specific redirects, then clean URL rewrites, then security rules, and finally the catch-all front controller. Each rule uses the [L] flag to prevent unnecessary processing of subsequent rules when a match is found.
Mastering mod_rewrite takes practice, but once you understand the anatomy of rules and conditions, the role of flags, and the processing order, you will be able to handle virtually any URL manipulation scenario with confidence.