How Servers Match URLs
When a request arrives, the server must decide how to handle it. This decision is based on location matching—comparing the request URL against configured patterns to find the appropriate handler.
The matching rules differ between servers, and understanding them is essential for debugging "why isn't my rule working?" problems.
Nginx Location Matching
Nginx uses location blocks to match URLs. The matching algorithm
has a specific order of precedence:
| Modifier | Type | Priority | Example |
|---|---|---|---|
= |
Exact match | 1 (highest) | location = /favicon.ico |
^~ |
Prefix (stops regex search) | 2 | location ^~ /static/ |
~ |
Case-sensitive regex | 3 | location ~ \.php$ |
~* |
Case-insensitive regex | 3 | location ~* \.(jpg|png)$ |
| (none) | Prefix match | 4 (longest wins) | location /api/ |
server {
# Exact match - highest priority
location = / {
# Only matches exactly "/"
return 200 "Home page";
}
# Prefix with ^~ - stops regex search
location ^~ /static/ {
# All /static/* requests, skip regex check
root /var/www;
}
# Regex match - case sensitive
location ~ \.php$ {
# Any URL ending in .php
fastcgi_pass 127.0.0.1:9000;
}
# Regex match - case insensitive
location ~* \.(jpg|jpeg|png|gif)$ {
# Image files, any case
expires 30d;
}
# Prefix match - longest match wins
location /api/ {
proxy_pass http://backend;
}
# Default - matches everything not matched above
location / {
try_files $uri $uri/ =404;
}
}
Nginx matching algorithm: First, Nginx finds the longest
prefix match. If that prefix has ^~, it wins. Otherwise, Nginx
checks regex patterns in order. If a regex matches, it wins. If no regex
matches, the longest prefix match wins.
Apache Location Matching
Apache uses different directives for URL matching:
| Directive | Matches | Example |
|---|---|---|
<Directory> |
Filesystem paths | <Directory /var/www/html> |
<Location> |
URL paths | <Location /api> |
<LocationMatch> |
URL paths (regex) | <LocationMatch "^/user/\d+"> |
<Files> |
Filenames | <Files "secret.txt"> |
<FilesMatch> |
Filenames (regex) | <FilesMatch "\.(txt|log)$"> |
# Match URL path
<Location /admin>
Require ip 192.168.1.0/24
</Location>
# Match URL with regex
<LocationMatch "^/api/v[0-9]+/">
ProxyPass http://api-backend/
</LocationMatch>
# Match specific files
<FilesMatch "\.(htaccess|htpasswd|ini|log)$">
Require all denied
</FilesMatch>Redirects: 301 vs 302
Redirects send the browser to a different URL. The status code matters:
| Code | Name | Meaning | Use Case |
|---|---|---|---|
301 |
Moved Permanently | URL changed forever | Domain changes, URL restructuring |
302 |
Found | Temporary redirect | Maintenance, A/B testing |
307 |
Temporary Redirect | Temporary, preserves method | POST redirects that must stay POST |
308 |
Permanent Redirect | Permanent, preserves method | Permanent POST redirects |
SEO impact: Search engines treat 301 and 302 differently. A 301 transfers "link juice" to the new URL. A 302 does not (the old URL remains indexed). Use 301 for permanent changes, 302 only for truly temporary situations.
server {
# Permanent redirect (301)
location /old-page {
return 301 /new-page;
}
# Temporary redirect (302)
location /promo {
return 302 /current-sale;
}
# Redirect entire site to new domain
server_name old-domain.com;
return 301 $scheme://new-domain.com$request_uri;
}# Simple redirect
Redirect 301 /old-page /new-page
# Redirect entire directory
RedirectMatch 301 ^/blog/(.*)$ /articles/$1
# Using mod_rewrite for complex redirects
RewriteEngine On
RewriteRule ^old-(.*)$ /new-$1 [R=301,L]URL Rewriting
Rewriting changes the URL internally without the browser knowing. The user sees one URL, but the server processes another:
# User requests: /products/widget
# Server processes: /product.php?slug=widget
# (Browser URL bar still shows /products/widget)Clean URLs
A common use case is "clean URLs"—hiding file extensions and query strings:
server {
# Try: exact file → .html version → directory → 404
location / {
try_files $uri $uri.html $uri/ =404;
}
# /about → /about.html (internal)
# /products/widget → /products/widget.html
}RewriteEngine On
# Remove .html extension
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.*)$ $1.html [L]
# Also redirect .html to clean URL (for SEO)
RewriteCond %{THE_REQUEST} \.html
RewriteRule ^(.*)\.html$ /$1 [R=301,L]Single-Page Application (SPA) Routing
SPAs handle routing client-side. The server must return index.html
for all routes, letting JavaScript take over:
server {
root /var/www/spa;
index index.html;
location / {
# Try file, then directory, then fall back to index.html
try_files $uri $uri/ /index.html;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}RewriteEngine On
RewriteBase /
# Don't rewrite files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Rewrite everything else to index.html
RewriteRule ^ index.html [L]Trailing Slash Normalization
/about and /about/ are technically different URLs.
For SEO and consistency, you should pick one and redirect the other:
# Remove trailing slash (except for directories)
rewrite ^/(.*)/$ /$1 permanent;
# OR: Add trailing slash to directories
location / {
try_files $uri $uri/ =404;
# Nginx automatically adds slash for directories
}
# Force trailing slash on all URLs
rewrite ^([^.]*[^/])$ $1/ permanent;RewriteEngine On
# Remove trailing slash (except root)
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [R=301,L]
# OR: Add trailing slash
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !(.*)/$
RewriteRule ^(.*)$ $1/ [R=301,L]
Pick a convention: It doesn't matter which you choose,
but be consistent. Most modern sites omit trailing slashes except for
actual directories. Search engines see /about and /about/
as duplicate content unless you redirect.
Query String Handling
Query strings (?key=value) are passed through by default in rewrites.
Sometimes you need to manipulate them:
# Preserve query string (default)
rewrite ^/old/(.*)$ /new/$1;
# /old/page?foo=bar → /new/page?foo=bar
# Drop query string
rewrite ^/old/(.*)$ /new/$1?;
# /old/page?foo=bar → /new/page
# Add to query string
rewrite ^/search/(.*)$ /search?q=$1;
# /search/widgets → /search?q=widgets
# Access query params in conditions
if ($arg_page) {
# $arg_page contains ?page=X value
}# Preserve query string (use QSA flag)
RewriteRule ^old/(.*)$ /new/$1 [QSA,L]
# Drop query string (default without QSA)
RewriteRule ^old/(.*)$ /new/$1 [L]
# New query string only
RewriteRule ^search/(.*)$ /search?q=$1 [L]
# Condition based on query string
RewriteCond %{QUERY_STRING} ^page=([0-9]+)$
RewriteRule ^list$ /list/%1? [R=301,L]Node.js URL Handling
In Node.js, you handle URL matching programmatically:
import { createServer } from 'node:http';
const server = createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
const query = url.searchParams;
// Exact match
if (path === '/') {
res.end('Home');
return;
}
// Prefix match
if (path.startsWith('/api/')) {
// Handle API routes
return;
}
// Pattern match with regex
const productMatch = path.match(/^\/products\/([a-z0-9-]+)$/);
if (productMatch) {
const slug = productMatch[1];
res.end(`Product: ${slug}`);
return;
}
// Redirect
if (path === '/old-page') {
res.writeHead(301, { 'Location': '/new-page' });
res.end();
return;
}
// 404
res.writeHead(404);
res.end('Not Found');
});
server.listen(3000);What's Next
URL handling is fundamental to how your server responds to requests. The next tutorial covers reverse proxying—forwarding requests to backend servers while transforming headers and paths along the way.