SecuredKit

Published: · Updated: · by

Bulk URL Encoding for SEO: The 2026 Redirect Map Guide

Bulk URL encoding for redirect maps, UTM tags and Cyrillic slugs: pick the right encoder, catch %25 double-encoding and NFC/NFD mismatches before they 404.

Bulk URL encoding for SEO comes down to one rule: encode every row of a redirect map or UTM list the same way, with the right function for each part of the URL, and check the output for double-encoding before it ships. A broken redirect map almost always comes down to one of four things: the wrong encoder, %2520, + versus %20, or two spellings of the same non-ASCII slug.

I'll be upfront about my own experience: I haven't run a large redirect migration myself. Everything below is reproducible, though. Each output was run in Node 22.23 or comes straight from the spec, so you can paste the same lines into a browser console and check me.

Pick the encoder per part of the URL, not per file

JavaScript has two encoders, encodeURIComponent() and encodeURI(), and HTML forms add a third convention. They escape different sets of characters, and using one on the wrong input is the most common way a redirect map breaks.

ModeEncodesLeaves as isUse it for
encodeURIComponent()Everything except A-Z a-z 0-9 - _ . ! ~ * ' ( )No URL structure survivesOne value: a query parameter, a UTM tag, a path segment
encodeURI()Spaces, non-ASCII, % and a few others; , / ? : @ & = + $ # plus the characters aboveA complete URL going into an href or a Location header
Form (application/x-www-form-urlencoded)Like encodeURIComponent(), but spaces become +Letters, digits, * - . _HTML form bodies, and query strings read by form parsers

Neither JavaScript function encodes the apostrophe. Plenty of cheat sheets show men's turning into men%27s, but that's what URLSearchParams or new URL() produce in a query string. Both spellings are valid, and they're different strings, so an exact-match redirect rule written with one won't fire for the other. Pick one convention per map.

The classic bug in hand-written redirect scripts is running a whole URL through encodeURIComponent(). It escapes the structure too, so https://example.com/page becomes https%3A%2F%2Fexample.com%2Fpage, which is no longer a URL.

My URL Encoder / Decoder exposes the three as explicit modes (Component, Full URL, Form +) and has an "Encode line by line" option that treats every row on its own. It runs in your browser tab; the map you paste stays on your machine.

UTM parameters: one raw & splits the campaign

A campaign name like Black Friday & Cyber Monday 🔥 needs Component mode, because a raw & ends the parameter. new URL() reads utm_campaign as Black Friday and turns the rest into a separate, nameless parameter. Encoded correctly, it's:

Black%20Friday%20%26%20Cyber%20Monday%20%F0%9F%94%A5

Links built by different tools (an ads platform, a spreadsheet formula, a hand-typed link) often encode the same name differently, and that's how one campaign ends up as several rows in an analytics report. Encode the value once, at the point where the link is built, and copy the result everywhere else.

Double-encoding: why %2520 is the 404 you'll see most

Here is the real encodeURIComponent() output for a value encoded twice:

Original value:   Men's Shoes
Encoded once:     Men's%20Shoes       correct (the apostrophe stays as is)
Encoded again:    Men's%2520Shoes     wrong: %25 is a literal "%"

A value gets encoded and saved, then another script (or the same one, run twice) encodes it again. The second pass escapes the % signs, since % is the one character both functions always escape. The result is valid syntax that points nowhere: the server answers 404, and in Search Console the URL lands under "Not found (404)" in the Page indexing report instead of passing its signals on to the new page. encodeURI() does the same, so encoding a full URL twice also gives %2520.

The audit check is one search: %25 followed by two hex digits. Unless the URL really contains a percent sign, every hit is a double-encoded row.

Cyrillic slugs: why I use Latin ones on this site

Google's URL structure guidelines say to use words in your audience's language and to percent-encode non-ASCII characters in links, so a Cyrillic slug isn't an SEO penalty in itself. I still don't use them. The Ukrainian pages on this site keep the English slug (/uk/blog/<same-slug>), and the reason is what happens to a Cyrillic path once it leaves the address bar.

The same article title as three possible slugs, run through new URL() in Node 22.23:

SlugCharacters typedCharacters on the wire
/uk/blog/як-створити-надійний-пароль36156
/uk/blog/yak-stvoryty-nadiinyi-parol (transliterated)3636
/uk/blog/how-to-create-a-strong-password (English)4040

Each Cyrillic letter is two UTF-8 bytes, so it becomes six characters of %D1%8F-style escapes. That 156-character version is what you see when you paste the link into a chat app or an email client that doesn't decode it, in server logs, in most crawl exports and in a redirect map. Nobody can read or diff that by eye.

The second problem is subtler, and it's the one I'd worry about most in a redirect map. Ukrainian has two letters that Unicode can store in two ways: й and ї exist as one precomposed code point (NFC) or as a base letter plus a combining mark (NFD). They render identically and encode differently:

encodeURIComponent('й')                   // '%D0%B9'
encodeURIComponent('й'.normalize('NFD'))  // '%D0%B8%CC%86'  (и + combining breve)
encodeURIComponent('ї'.normalize('NFD'))  // '%D1%96%CC%88'  (і + combining diaeresis)

Text pasted from one editor can be NFC, and the same word typed on another system can come out NFD. In an exact-match redirect rule, надійний in NFC and in NFD are two unrelated URLs. If you do use Cyrillic slugs, run every row through .normalize('NFC') before you encode it. My encoder doesn't normalize for you, so do it first.

Case is the third trap. RFC 3986 says %d0%b9 and %D0%B9 are equivalent, but new URL('https://example.com/%d0%b9').pathname returns /%d0%b9 unchanged, and a grep, a spreadsheet VLOOKUP or a CDN rule compares the raw string. Uppercase hex is the normalized form, so convert everything to it.

If you transliterate, use one system consistently. For Ukrainian, the official one is the Cabinet of Ministers 2010 table, the same one used for passport names: я at the start of a word becomes ya, elsewhere ia; и becomes y; й becomes i inside a word; the soft sign is dropped. That's how як створити надійний пароль becomes yak-stvoryty-nadiinyi-parol. Mixing systems (nadiynyi on one page, nadiinyi on the next) recreates the exact-match problem in ASCII.

Domains follow a different rule: an internationalized host name becomes Punycode, not percent-escapes. new URL('https://приклад.укр/пароль').href returns https://xn--80aikifvh.xn--j1amh/%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8C.

Reserved characters cheat sheet

The characters with a structural meaning are the reserved set in RFC 3986 §2.2. Encode them inside a value, and leave them alone where they are structure. Space and % aren't reserved: a space isn't allowed anywhere, and % is the escape character itself. The last two columns were checked in Node 22.

CharacterRole in a URLPercent-encodedencodeURIComponent()encodeURI()
(space)Not allowed%20encodesencodes
&Query parameter separator%26encodesleaves
=Key-value separator%3Dencodesleaves
?Start of the query string%3Fencodesleaves
#Start of the fragment%23encodesleaves
/Path segment separator%2Fencodesleaves
+Sub-delimiter; a space in form encoding%2Bencodesleaves
%The escape character%25encodesencodes
'Sub-delimiter%27leavesleaves

Watch + in particular: encodeURI() leaves it, and a form parser on the receiving end reads it as a space.

Batch workflow for a 500-row redirect map

The tempting shortcut is "decode everything, then encode everything once". It breaks on any row that already holds an escaped reserved character. A full-URL decode keeps %26, and the full-URL encode then escapes its %. A Component decode turns %26 back into &, which then acts as a separator:

Input:              /search?q=tom%26jerry%20show
Full URL decode:    /search?q=tom%26jerry show
 then encode:       /search?q=tom%2526jerry%20show    double-encoded
Component decode:   /search?q=tom&jerry show
 then encode:       /search?q=tom&jerry%20show        & is now a separator

The order that holds up:

  1. Normalize the destination column to NFC if it contains any non-ASCII text.
  2. Find the double-encoded rows first (search for %25). Decode those once in Full URL mode to strip the extra layer, and check the result.
  3. Rows with raw spaces or non-ASCII characters and no % at all: encode them in Full URL mode with "Encode line by line" on.
  4. Rows that mix existing escapes with raw characters: don't run them through a full-URL encode, because it escapes the existing % again. Encode just the broken value in Component mode, or normalize the row with the WHATWG URL parser (below).
  5. Uppercase the hex in every escape, then diff the output against the input. Rows with no visible change had nothing to fix.
  6. Put the cleaned column into your redirect rules (Nginx rewrite, Apache .htaccess, or your CDN's edge rules).

The parser from step 4, in Node 18+ or a browser console. It encodes raw characters and leaves existing escapes alone, but it can't guess intent: a raw & stays a separator.

new URL("https://example.com/a%20b/c d?q=tom%26jerry show").href
// 'https://example.com/a%20b/c%20d?q=tom%26jerry%20show'

Decoding works the same way in reverse, which helps when a client's crawl export arrives percent-encoded and you need readable paths for a content audit. A line with a lone % (say, 100%) isn't valid percent-encoding: decodeURIComponent() throws URIError: URI malformed. In my tool, one such line makes the whole batch stop with an "Invalid input" error rather than skipping it, so search for % not followed by two hex digits and fix those rows first.

Encoding hides nothing

Percent-encoding doesn't protect a value. Anyone can reverse it with one function call, exactly like Base64. If a UTM parameter or a redirect target contains something you don't want seen (an internal campaign codename, an unannounced product slug), encoding it changes nothing; keep it out of public URLs. Where the line between encoding, encryption and hashing sits is covered in encoding vs encryption.

The same goes for IDs. If each rule or campaign in the map gets a unique ID, don't build it from Date.now(), since a bulk script creates many rows in the same millisecond, or from a few characters of Math.random(), which is predictable and collides far sooner than people expect. A version 4 UUID from crypto.randomUUID() carries 122 random bits from the browser's cryptographic generator. The UUID Generator uses that call and makes up to 100 at once.

Before the redirects go live

Run the destination column through line-by-line mode, search the output for %25 and for lowercase hex, and spot-check a dozen redirects with curl -sI <old-url> to see the exact Location header your server sends, since that header is what Googlebot follows. If a parameter looks like neither plain text nor percent-encoding (a long run of letters and digits, often ending in =), it's probably Base64, and the Base64 decoder will show what's inside.

FAQ

Should spaces in a URL be %20 or +?

Use %20. In a path, + is a literal plus sign. In a query string, form parsers such as URLSearchParams, PHP and Python's parse_qs read + as a space, but decodeURIComponent() keeps it as +. %20 means a space to every parser.

Are %D0%B9 and %d0%b9 the same URL?

RFC 3986 says hex digits in a percent-escape are case-insensitive, so they decode to the same character (й). Exact-match tools don't decode first, though: a redirect rule, a log grep or a spreadsheet lookup treats them as two different strings. Uppercase is the normalized form, so convert everything to it.

Is it safe to paste a client's redirect map into an online URL encoder?

Only if the tool does the work in your browser. A redirect map often exposes unreleased URL structures, campaign names and internal paths. Open the Network tab in DevTools while you paste: a client-side tool sends no request.

Try the Free Password Generator

Generate strong, secure passwords instantly. 100% private and client-side.

Open Password Generator