Let Nginx Add the Trailing Slash, Not WordPress
If your WordPress permalinks end in a slash, every request for /some-page gets a 301 to /some-page/. WordPress handles this itself, so most people never think about it. The redirect works, but it is one of the most expensive ways to send a single Location header.
What WordPress does for that redirect
The redirect comes from redirect_canonical(), which runs on the template_redirect hook. By that point WordPress has already:
- booted PHP and loaded core
- loaded every active plugin and the theme
- connected to the database and run the main query to work out what the URL refers to
Only then does it notice the missing slash and send the 301. The browser follows it and the real page load starts from scratch.
Page caching doesn’t help. Disk-based page caches (W3 Total Cache in Disk: Enhanced mode, WP Super Cache, WP Rocket) are served by nginx rules that match the canonical, slashed URL. A slashless request doesn’t match, so it falls through to PHP every time, even when the page it is about to redirect to is sitting in the cache.
Slashless requests are more common than you’d expect. Hand-typed links, links from other sites, social shares, link checkers and crawlers all produce them.
Why do it in nginx
- No PHP worker is used. nginx answers from the rewrite phase, before the request gets anywhere near PHP-FPM. Workers stay free for requests that need them.
- It keeps working under load. When PHP-FPM is saturated, WordPress redirects queue or fail along with everything else. nginx redirects don’t.
- It is far quicker. There is no bootstrap and no database query, just a string match and a response.
- A CDN can cache it. A plain 301 from nginx is easy for Cloudflare or any other CDN to cache at the edge, so repeat requests may never reach the server.
- You can tell who sent it. nginx and WordPress redirects look different in the response headers and the access log, so it is easy to confirm which one answered.
Before you start
- Your permalink structure must end in a slash. If it doesn’t, WordPress redirects the other way and you will create a loop.
- Custom extensionless routes. Webhook endpoints or custom API paths outside
wp-jsonthat must stay slashless need adding to the first exclusion in the config below. - Slugs containing a dot. These are treated as files and skipped, so they fall back to WordPress’s own redirect. Nothing breaks.
- Non-www to www. If another rule redirects the hostname first, a slashless request on the wrong host takes two hops. Both are handled by nginx, so it is still cheap. Replace
$hostin the redirect with your canonical hostname if you want it done in one.
The config
Two parts: a map in the http context, and one line in the server block.
# http context, e.g. /etc/nginx/conf.d/trailing-slash.conf
# Target for slashless front-end URLs; empty means leave the request alone
map "$request_method $request_uri" $add_slash_to {
default "";
# WordPress internals and ACME challenges
"~^\S+ /(?:wp-admin|wp-json|wp-content|wp-includes|\.well-known)(?:/|\?|$)" "";
# Anything whose path ends in a file extension
"~^\S+ [^?]*\.[A-Za-z0-9]+(?:\?|$)" "";
# GET/HEAD only, path not ending in a slash; query string preserved
"~^(?:GET|HEAD) (?<slash_path>[^?]*[^/?])(?<slash_query>\?.*)?$" "$slash_path/$slash_query";
}
# server block
if ($add_slash_to) { return 301 https://$host$add_slash_to; }
Regex entries in a map are tested in order and the first match wins, so the two exclusions have to come before the rule that does the work.
What each rule is for
- Internals are skipped.
wp-admin,wp-json,wp-contentandwp-includeshave their own handling, and.well-knownpaths such as ACME challenges must not be redirected. - Files are skipped. Any path ending in
.somethingis left alone. That covers images, CSS, JS, PDFs,robots.txt, XML sitemaps and every.phpendpoint. - Only GET and HEAD are redirected. A 301 on a POST turns it into a GET in most clients, which silently drops the form data.
- It matches on
$request_uri, not$uri.$uriis decoded and normalised, so building a redirect from it can mangle encoded characters in slugs.$request_uriis the raw request, so the path and query string go back out exactly as they came in.
An if at server level containing only a return is one of the safe uses of if in nginx.
ISPConfig
The map has to live at http level, so it goes in its own file under /etc/nginx/conf.d/. The if line goes in the site’s nginx Directives field. It only uses if and return, so it isn’t caught by the directive blacklist that ISPConfig 3.3.3 introduced.
Create the map file first and run nginx -t. If the directives reference a variable that doesn’t exist yet, ISPConfig’s config test fails and it quietly restores the previous vhost, while the Directives field still shows your new text.
Checking it works
# 301 with a Location ending in a slash
curl -sI https://example.com/some-page | grep -iE '^(HTTP|location)'
# Files and the API are untouched
curl -sI https://example.com/robots.txt | head -1
curl -sI https://example.com/wp-json | head -1
To tell who sent a redirect:
- Response headers: WordPress adds
x-redirect-by: WordPress. An nginx redirect doesn’t have it. - Access log: a WordPress redirect has a body size of
0. An nginx redirect to a GET request carries its small built-in HTML body, typically 162 or 178 bytes.
If you are behind Cloudflare, server: cloudflare appears either way, and a cf-cache-status: HIT on the 301 means the edge answered it without contacting your server at all.