Redirects keep old links and search rankings working when URLs change; rewrites map pretty URLs to internal paths. Nginx has two tools for this — return and rewrite — and choosing the right one keeps configs fast and readable.
Prefer return for redirects
return sends a response immediately — no regex processing, no further rules:
# Whole domain moved
server {
server_name old-example.com www.old-example.com;
return 301 https://example.com$request_uri;
}
# One page moved
location = /old-page/ {
return 301 /new-page/;
}
# Section moved, keep the rest of the path
location ^~ /blog/2019/ {
return 301 /archive$request_uri;
}
- 301 — permanent (search engines transfer ranking to the new URL).
- 302 / 307 — temporary.
- 308 — permanent and keeps the method (POST stays POST).
See 301 vs 302 redirects.
rewrite: when you need regex captures
rewrite regex replacement [flag];
# /products/123 → /shop/item.php?id=123 (internal, URL in the browser stays the same)
rewrite ^/products/(\d+)/?$ /shop/item.php?id=$1 last;
# Old category structure → new, as a redirect
rewrite ^/category/(.+)$ /topics/$1 permanent;
Flags:
| Flag | Behaviour |
|---|---|
last |
Stop processing rewrites; search locations again with the new URI |
break |
Stop processing rewrites; continue in the current location |
redirect |
302 redirect |
permanent |
301 redirect |
| (none) | Continue with the next rewrite rule |
If the replacement starts with http:// or https://, Nginx always sends a redirect.
Query strings: rewrite appends the original query string automatically. To drop it, end the replacement with ?:
rewrite ^/search/(.*)$ /find?q=$1? permanent;
Many redirects: use a map
Hundreds of redirects as individual location or rewrite lines are slow to evaluate and hard to maintain. A map is a fast hash lookup:
# http {} context
map $request_uri $redirect_to {
default "";
/old-page/ /new-page/;
/about-us/ /about/;
/2020/06/hello/ /blog/hello/;
~^/docs/v1/(.*)$ /docs/v2/$1;
}
server {
if ($redirect_to) {
return 301 $redirect_to;
}
# …
}
You can keep the map in a separate file (include /etc/nginx/redirects.map;) and generate it from a spreadsheet or your CMS. This is exactly how large site migrations handle thousands of old URLs.
Common patterns
# Force trailing slash on directory-style URLs (not files)
rewrite ^([^.]*[^/])$ $1/ permanent;
# Remove trailing slash
rewrite ^/(.*)/$ /$1 permanent;
# Remove .php extension from URLs
location / {
try_files $uri $uri/ $uri.php?$args;
}
location ~ \.php$ {
if ($request_uri ~ ^/([^?]*)\.php($|\?)) { return 301 /$1$is_args$args; }
# … fastcgi config …
}
# Lowercase URLs (needs Perl or Lua module) — usually better handled in the app
For HTTP→HTTPS and www handling, see Nginx HTTPS redirects.
Converting .htaccess rules
Apache’s RewriteRule maps to Nginx’s rewrite fairly directly, but:
- Apache rules in
.htaccessmatch paths without the leading slash; Nginx matches with it (^/products/). RewriteCond %{REQUEST_FILENAME} !-f/!-dpatterns becometry_files.[R=301,L]→permanent;[L]→last;[QSA]is Nginx’s default behaviour.
Example:
RewriteRule ^old/(.*)$ /new/$1 [R=301,L]
rewrite ^/old/(.*)$ /new/$1 permanent;
Online converters help, but always review the output.
Avoid “if is evil” problems
Inside location, if only safely supports return and rewrite … last. Don’t put proxy_pass, try_files or other directives inside if blocks — they behave unexpectedly. Use map, separate locations or try_files instead.
Test before going live
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/old-page/ | grep -iE '^(HTTP|location)'
curl -sIL https://example.com/about-us/ | grep -iE '^(HTTP|location)' # follow the chain
Watch for chains (A→B→C) and loops. Each hop costs a round trip and dilutes SEO signals — redirect directly to the final URL. Enable rewrite_log on; with error_log … notice; on a test server to see how rules are applied.
Related: Nginx try_files, website migration SEO and Nginx location blocks.