Friendly URLs with .htaccess: Definitive Guide from a Real WHOIS Migration#
The power of clean URLs#
Transforming technical URLs like ?d=domain.com&page=wsget into clean, semantic paths such as /tool/whois/info/domain.com/197 is far more than a cosmetic upgrade. In a production environment, this structural change directly impacts SEO authority, request traceability in access logs, Click-Through Rate (CTR), and long-term route predictability. It also allows for backend maintenance without breaking legacy links.
This article documents the exact production procedure I implemented to migrate a high-traffic WHOIS tool from query-string routing to a friendly URL architecture while maintaining full compatibility with the existing PHP backend ($_GET), SEO equity via 301 redirects, and server security/performance practices.
1) Initial problem: functional query strings, but bad for SEO#
Legacy URL:
https://example.com/tool/whois/?d=domain1.com
Target architecture:
- domain base route:
/tool/whois/domain.com - information route:
/tool/whois/info/domain.com - identifier route:
/tool/whois/info/domain.com/197
Core requirements:
- Maintain parameter compatibility with the backend
index.php. - Avoid redirect loops.
- Prevent SEO ranking loss from indexed legacy URLs.
- Provide a stable fallback matching paths with or without trailing slashes.
2) Technical diagnosis: where rewrites fail#
Before deploying rules in .htaccess, we audited the common failure points in the mod_rewrite engine:
- Rule Ordering: More generic rules capturing requests before more specific ones can execute.
- Conflict Loops: Mismatches between external
[R=301]redirects and internal rewrites. - Parameter Leakage: Old query strings persisting into the clean URL and causing "dirty" canonicals.
- QUERY_STRING Mutability: Previous rules rewriting the query string, causing subsequent validations to fail.
3) Design principle: two-phase pipeline#
An architecture that runs predictably and loop-free divides rewrite tasks into two independent phases:
- Phase 1: External 301 Redirects (Canonical consolidation of the old URL structure to the new one).
- Phase 2: Internal Rewrites (Transparent mapping of the clean path to the real script,
index.php).
4) Clean routing and internal rewrites#
To capture the domain name securely, we utilized the restricted capture group ([^/]+), which matches any sequence of characters except a slash, preventing paths from bleeding into subsequent segments.
On the most specific route (the one containing an ID), we use the modern [END] flag of Apache 2.4+. The END flag stops all subsequent rewrite processing immediately, cutting off internal loop sub-requests:
# Most specific route first (with ID)
RewriteRule ^tool/whois/info/([^/]+)/([^/]+)/?$ index.php?page=wsget&d=$1&id=$2 [L,QSA,END]
# Information route
RewriteRule ^tool/whois/info/([^/]+)/?$ index.php?page=wsinf&d=$1 [L,QSA]
# Base domain route
RewriteRule ^tool/whois/([^/]+)/?$ index.php?page=whois&d=$1 [L,QSA]
# Tool root route
RewriteRule ^tool/whois/?$ index.php?page=whois [L,QSA]
5) Safe 301 redirects using THE_REQUEST and QSD#
Many setups rely on the mutable %{QUERY_STRING} variable to identify the old URL parameters. However, because it can be altered by intermediate rules, we swap it for the raw environment variable %{THE_REQUEST}. This variable contains the exact original HTTP request line sent by the browser (e.g., GET /tool/whois/?page=wsinf HTTP/1.1) and remains completely immutable.
Additionally, instead of appending a ? to the end of the destination URL to discard the legacy query string, we use the official QSD (Query String Discard) flag:
# Redirect old query string with ID to clean route
RewriteCond %{THE_REQUEST} \s/tool/whois/info/([^/]+)/?\?page=wsget&id=([^&\s]+) [NC]
RewriteRule ^tool/whois/info/([^/]+)/?$ tool/whois/info/$1/%2 [R=301,L,QSD]
# Redirect legacy information route
RewriteCond %{THE_REQUEST} \s/tool/whois/\?([^&\s]+)&page=wsinf [NC]
RewriteRule ^tool/whois/?$ tool/whois/info/%1 [R=301,L,QSD]
6) Final production .htaccess file#
# ====================================================================
# .htaccess - Friendly URLs Routing Configuration (WHOIS)
# ====================================================================
RewriteEngine On
RewriteBase /
# 1. SECURITY AND SLASH Normalization
# Disable directory listing
Options -Indexes
# Custom 404 Error page
ErrorDocument 404 /404.php
# Block reading of sensitive files
RewriteRule ^(\.git|\.env|composer\.json) - [F,L]
# Force HTTPS (accounting for load balancers/reverse proxies)
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# Normalization: Add trailing slash for real directories
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_URI} !(.*)/$
RewriteRule ^(.*)$ $1/ [R=301,L]
# Normalization: Remove trailing slash from virtual paths
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ /$1 [R=301,L]
# 2. PHASE 1: CANONICAL 301 REDIRECTS (Legacy -> New)
RewriteCond %{THE_REQUEST} \s/tool/whois/info/([^/]+)/?\?page=wsget&id=([^&\s]+) [NC]
RewriteRule ^tool/whois/info/([^/]+)/?$ tool/whois/info/$1/%2 [R=301,L,QSD]
RewriteCond %{THE_REQUEST} \s/tool/whois/\?([^&\s]+)&page=wsinf [NC]
RewriteRule ^tool/whois/?$ tool/whois/info/%1 [R=301,L,QSD]
# 3. PHASE 2: INTERNAL REWRITE (Routing clean requests to index.php)
RewriteRule ^tool/whois/info/([^/]+)/([^/]+)/?$ index.php?page=wsget&d=$1&id=$2 [L,QSA,END]
RewriteRule ^tool/whois/info/([^/]+)/?$ index.php?page=wsinf&d=$1 [L,QSA]
RewriteRule ^tool/whois/([^/]+)/?$ index.php?page=whois&d=$1 [L,QSA]
RewriteRule ^tool/whois/?$ index.php?page=whois [L,QSA]
7) Debugging rules and server logs#
If a rule behaves unexpectedly, you can enable write logging within the Apache VirtualHost configuration block (note: this configuration cannot be declared inside .htaccess):
# Enable mod_rewrite debugging in Apache 2.4+
LogLevel alert rewrite:trace3
Trace levels range from trace1 to trace9 (with trace3 being sufficient to check matches without clogging disk I/O in staging environments).
Regex and CLI testing#
- Syntax Validation: Verify rules syntax before applying changes using
apache2ctl -torhttpd -t. - Rewrite Validation: List compiled rewrite rules using:
apache2ctl -t -D DUMP_REWRITE_RULES
- Regex Testing: Use RegEx101 in PCRE mode to validate your capture groups.
8) Performance considerations (vhosts vs .htaccess)#
Using .htaccess files introduces processing overhead on high-traffic Apache servers. This is because Apache must scan and parse .htaccess files recursively for every single subdirectory check.
Scale Best Practice: If you have root access to the server, disable .htaccess globally with AllowOverride None and migrate all rewrite rules directly into the VirtualHost block in your httpd.conf or apache2.conf. This compiles the rules directly into memory, eliminating disk I/O scans on every request.
RewriteBase - when to use and when to skip#
When .htaccess lives at the domain root (/), the RewriteBase / directive is redundant - Apache already assumes / as the implicit base. Including it at the root causes no harm, but adds no value.
In subdirectories (e.g., .htaccess inside /tool/), omitting RewriteBase can cause incorrect behavior: Apache will evaluate RewriteRule patterns against the full URL path, ignoring the subdirectory prefix. In those cases, declare it explicitly:
# .htaccess inside /tool/whois/
RewriteBase /tool/whois/
FollowSymLinks vs SymLinksIfOwnerMatch#
The Options +FollowSymLinks option is often required for mod_rewrite to function. However, it removes symlink attack protection - on shared hosting, a malicious user could create a symlink pointing to another user's files.
Options +SymLinksIfOwnerMatch is the safer alternative: it allows symlink traversal only when the link and its target share the same owner. The trade-off is one extra system call (lstat()) per request - negligible on modern hardware.
# Shared hosting: prefer SymLinksIfOwnerMatch
Options +SymLinksIfOwnerMatch
# Dedicated servers with full control: FollowSymLinks is sufficient
# Options +FollowSymLinks
9) Rewriterule flags reference#
| Flag | Description | Use Case |
|---|---|---|
[L] (Last) | Halts rule matching in the current pass. | Standard internal rewrites. |
[END] | Halts all subsequent rewrite loops completely. | Prevent recursive loops on sub-routes. |
[R=301] | Emits a permanent HTTP redirect. | SEO-friendly URL migration. |
[QSA] | Appends new parameters to rewritten query strings. | Preserve extra filters in rewritten queries. |
[QSD] | Discards the incoming query string. | Cleanup legacy parameters on redirects. |
[NC] | Case-insensitive match. | Handle variations in URL casing. |
[F] | Returns an HTTP 403 Forbidden response. | Block access to sensitive files. |
10) Preventing pagerank split during migration#
During the migration window - while legacy query-string URLs are still indexed and actively crawled by Googlebot - there is a risk of PageRank split: the crawler may treat both the old and new URLs as duplicate content and divide link authority between them, even with the 301 redirect in place.
To mitigate this, explicitly signal to crawlers that the legacy canonical URLs with query strings should not be indexed during the transition:
# Add to VirtualHost or .htaccess, after the security block
# Signal to Googlebot not to index legacy query-string URLs
<If "%{QUERY_STRING} =~ /page=ws(get|inf|hois)/">
Header always set X-Robots-Tag "noindex, nofollow"
</If>
Technical conclusion#
Migrating to friendly URLs with Apache .htaccess is a core routing refactor, not a cosmetic theme change. By leveraging a structured two-stage pipeline - isolating legacy requests via THE_REQUEST, employing QSD for cleanup, and applying the END flag to prevent processing loops - you achieve a robust and secure routing architecture optimized for search engines and performance.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.


Comments
Join the discussion below.
0 comments