# Redirect mixed-case URLs to lowercase
Source: https://docs.webgee.com/en/performance/redirect-mixed-case-urls-to-lowercase







Make every request for your site resolve to one lowercase URL, so LiteSpeed
serves a single cached copy of each page instead of one copy per capitalisation.

Applies to any site on shared, reseller or Premium hosting that sits behind
Cloudflare, on any Cloudflare plan including Free. You need access to the
Cloudflare account the domain uses — see
[Put your site behind Cloudflare](/en/domains-dns/how-do-i-enable-cloudflare-for-my-websites).

## Why capitalisation splits the cache [#why-capitalisation-splits-the-cache]

LiteSpeed Cache keys each entry on the request URI as a literal string, so these
are two separate entries:

```txt
https://example.com/Blog/My-Great-Article
https://example.com/blog/my-great-article
```

Same page, same content, same canonical tag — two cache entries, each warmed on
its own.

Capitalised requests arrive continuously, from:

* **category, tag and custom post type slugs** created with a capital letter and
  never normalised,
* **old internal links** written before the slugs were tidied,
* **backlinks and syndicated copies** that kept whatever casing was in an
  `og:url` or in a pasted link,
* **Google's index**, which keeps requesting the exact string it crawled. A
  canonical tag consolidates indexing; it does not stop the request arriving at
  the URL that was indexed.

The last one is the expensive one. Search traffic landing on a rarely-requested
capitalised variant misses the cache, so PHP and MySQL rebuild the page on every
visit, while the lowercase version sits cached and fast serving almost nobody.
Core Web Vitals field data (CrUX) is collected from real visits, so the measured
experience of that page becomes the slow, uncached path.

It is easy to miss, because:

* the **overall cache hit ratio stays healthy** — a small set of permanently
  cold URLs disappears into the aggregate,
* **you type lowercase URLs** when you test the site, and so does everyone else
  who works on it,
* **SEO crawlers normalise URLs** before fetching them, so an audit will not
  reproduce it.

Apache and LiteSpeed do not lowercase URLs by themselves, and that is
deliberate: paths are case-sensitive on Linux, and real files depend on it.

## Check whether it affects you [#check-whether-it-affects-you]

Download a [raw access log](/en/performance/raw-access-logs) and list the
successful requests for paths that contain a capital letter and are not files:

```bash
awk '$9 == 200 && $7 ~ /[A-Z]/ && $7 !~ /\./ {print $7}' example.com-ssl_log | sort | uniq -c | sort -rn | head -20
```

Every path with a meaningful count is a page that has been served from PHP
rather than from cache on each of those visits.

In Google Search Console, the same problem shows up in **Performance → Pages**
as one page listed twice under two different casings.

## Add the redirect rule in Cloudflare [#add-the-redirect-rule-in-cloudflare]

1. In Cloudflare, select the domain, then open **Rules → Overview**.

   <img alt="The Cloudflare Rules Overview page, filtered to Redirect Rules, with the Create rule button at the top right" src="__img0" />

2. Select **Create rule**, then **Redirect Rules**.

3. In **Rule name**, enter a name you will recognise later, such as
   `UnifiedURLs`.

4. Under &#x2A;*If incoming requests match…**, select **Custom filter expression**.

5. Paste this into the expression box. If the form shows the visual builder
   rather than a text box, select **Edit expression** in the corner to switch to
   text.

   ```txt
   (http.request.uri.path contains "A" or http.request.uri.path contains "B" or http.request.uri.path contains "C" or http.request.uri.path contains "D" or http.request.uri.path contains "E" or http.request.uri.path contains "F" or http.request.uri.path contains "G" or http.request.uri.path contains "H" or http.request.uri.path contains "I" or http.request.uri.path contains "J" or http.request.uri.path contains "K" or http.request.uri.path contains "L" or http.request.uri.path contains "M" or http.request.uri.path contains "N" or http.request.uri.path contains "O" or http.request.uri.path contains "P" or http.request.uri.path contains "Q" or http.request.uri.path contains "R" or http.request.uri.path contains "S" or http.request.uri.path contains "T" or http.request.uri.path contains "U" or http.request.uri.path contains "V" or http.request.uri.path contains "W" or http.request.uri.path contains "X" or http.request.uri.path contains "Y" or http.request.uri.path contains "Z") and http.request.uri.path.extension eq ""
   ```

   The obvious version of this is `http.request.uri.path matches "[A-Z]"`, but
   the `matches` operator needs a Business or Enterprise plan. The chain of
   `contains` comparisons does the same job on every plan.

6. Under &#x2A;*Then…**, set **Type** to `Dynamic` and paste this as the
   **Expression**:

   ```txt
   concat("https://", http.host, lower(http.request.uri.path))
   ```

7. Set **Status code** to `301 - Permanent Redirect` and tick **Preserve query
   string**.

8. Under **Place at**, select **First**. The redirect should happen before any
   other rule does work on a URL that is about to change.

9. Select **Save**.

<img alt="The Edit Single Redirect form, showing the custom filter expression, the dynamic redirect expression, the 301 status code and the rule order" src="__img1" />

<Callout type="warn">
  `http.request.uri.path.extension eq ""` is the half of the expression that
  keeps real files out of the redirect, and it matters. Uploads are full of
  names like `IMG_2024.jpg` and `Banner-Photo.png`, and the filesystem is
  case-sensitive — lowercasing those paths turns working images into 404s. Leave
  it in. The exception is a site whose permalinks end in `.html`: those pages are
  excluded along with the files.
</Callout>

### Why 301 and not 302 [#why-301-and-not-302]

A permanent redirect tells search engines to consolidate ranking signals onto
the lowercase URL instead of indexing both. Browsers and downstream caches also
store a 301, so a repeat visit to the capitalised URL is redirected without
another round trip to Cloudflare.

## Check it worked [#check-it-worked]

The capitalised URL should now answer with a redirect:

```bash
curl -sI https://example.com/Blog/My-Great-Article | head -n 3
```

Look for `HTTP/2 301` and `location: https://example.com/blog/my-great-article`.

Request the lowercase URL twice and check that it is cached:

```bash
curl -sI https://example.com/blog/my-great-article | grep -i litespeed
```

The second request should return `x-litespeed-cache: hit` — see
[Check whether LiteSpeed Cache is working](/en/hosting/wordpress/how-do-i-check-if-litespeed-cache-is-working-on-my-site).

Then confirm a file with capitals in its name still loads. This must return
`200`:

```bash
curl -so /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/2026/01/IMG_2024.jpg
```

## If it did not work [#if-it-did-not-work]

* **The capitalised URL still returns 200.** The hostname is not proxied through
  Cloudflare — the DNS record needs to be orange-clouded, or rules never see the
  traffic. Check as well that the rule shows as **Active**.
* **Images have become 404s.** The extension test is missing, or the brackets
  around the `or` chain were dropped when pasting. `and` binds more tightly than
  `or`, so without the brackets the exclusion applies only to the final
  `contains "Z"`. Correct the expression, then purge the Cloudflare cache: the
  wrong redirect has been cached.
* **The site is in a redirect loop.** Something is sending the lowercase URL
  back to a capitalised one — a plugin, a `.htaccess` rule, or a WordPress
  **Site Address** saved with a capital letter. Fix it at that end rather than
  removing this rule.
* **A URL still behaves the old way after you edit the rule.** Browsers cache
  301s aggressively. Test with `curl` or a private window rather than the tab
  you have been using.

## Why this cannot go in .htaccess [#why-this-cannot-go-in-htaccess]

The snippet that circulates for this problem uses a rewrite map:

```apache
RewriteMap lc int:tolower
```

`RewriteMap` is valid only in server config and virtual host context, in Apache
and in LiteSpeed alike. In a `.htaccess` file Apache returns a 500 error, and on
shared hosting you have no access to the level where the directive would be
allowed. There is no `.htaccess`-only equivalent — mod\_rewrite cannot change the
case of a string without a map.

If the site is not behind Cloudflare, do it in PHP instead. Create
`wp-content/mu-plugins/` if it does not exist, and save this as
`wp-content/mu-plugins/lowercase-urls.php`. Must-use plugins load automatically
and need no activation:

```php
<?php
/**
 * Plugin Name: Lowercase URL redirect
 */
add_action( 'init', function () {
	if ( is_admin() || wp_doing_ajax() || 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
		return;
	}

	$parts = explode( '?', $_SERVER['REQUEST_URI'] ?? '', 2 );
	$path  = $parts[0];

	if ( $path === strtolower( $path ) || strpos( $path, '/wp-json/' ) === 0 ) {
		return;
	}

	wp_safe_redirect( strtolower( $path ) . ( isset( $parts[1] ) ? '?' . $parts[1] : '' ), 301 );
	exit;
} );
```

This only runs for requests WordPress handles, so files in `wp-content/uploads`
are never touched. It is a fallback rather than a replacement: the request still
reaches PHP, which is the work the Cloudflare rule avoids entirely.

## Related [#related]

* [Check whether LiteSpeed Cache is working](/en/hosting/wordpress/how-do-i-check-if-litespeed-cache-is-working-on-my-site)
* [Put your site behind Cloudflare](/en/domains-dns/how-do-i-enable-cloudflare-for-my-websites)
* [Download raw access logs](/en/performance/raw-access-logs)
* [QUIC.cloud or Cloudflare](/en/hosting/wordpress/litespeed-comparing-quic-cloud-and-cloudflare)
