Bug in non-trailing slash rewrite

Hey @baszalmstra et al,

We still have an open feature request for this. If/when this is implemented, or a similar/alternate solution, we will respond to this post. Thanks for your input!

As @danielstocks pointed out correctly, there is no obvious way to add a server-side redirect to add a trailing slash due to URL normalization in the Netlify CDN.

In my case, it would have been tedious to add the client-side JavaScript redirect in multiple projects, so I came up with a server-side workaround instead.

The idea: In order to handle my-domain.com/page and my-domain.com/page/ differently, we call a netlify function for both of them, which can distinguish the two and act accordingly:

  • For /page return a redirect to /page/
  • For /page/ return the page root

Assuming that the content for /page/ is hosted at https://separately-deployed-subpage.netlify.app, we can setup the following rules in _redirects:

# Due to URL normalization, this rule actually matches /page and /page/
/page/   /.netlify/functions/addSlash  200

# This matches all other URLs that start with /page/
/page/*  https://separately-deployed-subpage.netlify.app/:splat  200

And the implementation of the netlify function in /.netlify/functions/addSlash.js (note that you will also need to add a package.json file with node-fetch as a dependency:

const fetch = require("node-fetch");

exports.handler = function (event, context, callback) {
  if (event.path.endsWith("/")) {
    // Return the page root
      .then((response) => response.text())
      .then((body) => {
        callback(null, {
          statusCode: 200,
  } else {
    // Return a redirect to /page/
    const location = event.path + "/";
    callback(null, {
      statusCode: 301,
      headers: { location },

This can also be extended to handle different subpages, since the path of the subpage is available in event.path.

also does anyone know why the non-trailing slash url with the 301 redirect header still sends the full response body?

curl -v 'https://probely.com/pricing' | gunzip

for example, when serving locally, Jekyll (WEBrick) response is just an anchor tag for clients that don’t follow redirects

< Server: WEBrick/1.7.0 (Ruby/3.0.2/2021-07-07)
< Date: Thu, 29 Jul 2021 14:47:11 GMT
< Content-Length: 48
< Connection: Keep-Alive
<HTML><A HREF="/pricing/">/pricing/</A>.</HTML>

Hi, @cdeath. This is a known issue that the 301 is sending a body when it should not.

We will post an update here, however, if this issue is confirmed to be fixed.

thanks! is there a thread or github issue for this?

also you can still send a minimalistic body like the pattern described.

Hi, @cdeath. This thread is cross-linked to the issue but the issue itself is private. Watching this thread itself would be my best recommendation for how to be notified if the issue is know to be resolved.

Also, thank you for the suggestion about a smaller body with an <a> tag pointing to the redirected path. We will take that into consideration and I did add that suggestion to the open issue.

Hi, @cdeath. We have a potential fix for this. Would you be willing to have us enable the change for your team’s sites?

If so, please let us know if we have your permission to do so.

sure! you break it, you fix it :sunglasses:

Hi @cdeath,

Could you specify which domain(s) exactly you’d like this to be enabled for?

thanks! :metal:

Hi @cdeath

Sorry for the delay here, but it has now been enabled for the domain you requested. Let us know if it works.

hey! thanks a lot!

it seems to be working fine, but the non-trailing slash url is outputing the same headers as the trailing slash.

in the _headers file i have headers set specifically for the trailing slash path.


curl -v https://probely.com/enterprise
*   Trying
* Connected to probely.com ( port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
*  subject: C=US; ST=CA; L=San Francisco; O=Cloudflare, Inc.; CN=probely.com
*  start date: Nov 26 00:00:00 2020 GMT
*  expire date: Nov 25 23:59:59 2021 GMT
*  subjectAltName: host "probely.com" matched cert's "probely.com"
*  issuer: C=US; O=Cloudflare, Inc.; CN=Cloudflare Inc ECC CA-3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fe3da00d600)
> GET /enterprise HTTP/2
> Host: probely.com
> User-Agent: curl/7.64.1
> Accept: */*
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 301 
< date: Tue, 24 Aug 2021 10:13:53 GMT
< content-type: text/html; charset=UTF-8
< cache-control: public, max-age=0, must-revalidate
< content-security-policy: default-src 'self' https: data: wss: blob: 'unsafe-inline'; script-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; report-uri https://probely.report-uri.com/r/d/csp/enforce
< link: </assets/fonts/inter/Inter-Roman.var.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin=anonymous
< permissions-policy: microphone=(), camera=(), geolocation=(self "https://probely.com")
< referrer-policy: strict-origin-when-cross-origin
< x-xss-protection: 1; mode=block
< location: /enterprise/
< x-nf-request-id: 01FDVTDTXB2RHW6REPG1150903
< x-frame-options: DENY
< age: 0
< cf-cache-status: DYNAMIC
< expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
< report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=%2Bnto65rCxjwjqsESkLWOSxsqln3kZAJNVAYuSPGyJatmffWf0jsdVjWbnRWZgQStdYrEdE9IRdDdpzKcbTqlLJJUNodm%2BxvlPnyZXgXC8Sa1IvRxmdG99tt9BNoU"}],"group":"cf-nel","max_age":604800}
< nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
< strict-transport-security: max-age=31536000; includeSubDomains; preload
< x-content-type-options: nosniff
< server: cloudflare
< cf-ray: 683bcf7bc89003b2-LIS
* Connection #0 to host probely.com left intact
* Closing connection 0

imho shouldn’t be outputing headers like server push or CSP for the 301 redirect

Hey there! Just a head’s up, we normalise URLs at our end so you won’t be able to define a header rule “just” for a trailing slash suffixed URL.

We have the same problem. What’s the status of this after 4 years?

Can you share your site info?

See discussion in: Redirects with :splat broken - #6 by jasiqli

You can check the discussion under Redirects with :splat broken - #6 by jasiqli

I went with the hack by @rkaravia for now but a) it is super ugly and confusing for new/other maintainers and b) it reduces the number of actually useful function calls that we can do.

Disclaimer because my hack has been called ugly:
a) I’m only using this for a personal site and would not advocate that you use it for a site that has more than 1 person on it :wink:
b) I’m not using netlify functions for anything else. If you do and/or if you have a lot of traffic, this is probably a bad idea.

1 Like

Sorry! Thank you for clarifying and for the hack in general. I understand its limitations and therefore I called it ugly. However, it is the only thing that reliably works. I just wanted to raise awareness at netlify that this cannot stay the only way to achieve such simple redirects and a built-in manual solution should be provided if the general behavior of the combination of path normalization/trailing slashes and redirects cannot be changed by default.

1 Like

Hehe, no worries, I’m actually happy that this was still at least maybe somewhat useful to someone. But of course I would also prefer if there was a more stable and less hacky solution.