Routing and URL Handling

Location matching, rewrites, redirects, and clean URLs

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.