You build a search link, a user types Tom & Jerry, and your backend receives a query of just Tom . Or you encode a redirect URL twice and the login page 404s on %2520. Both bugs come from the same place: encoding the wrong thing with the wrong function.
Short answer: use
encodeURIComponent()on each value you put into a URL (a query parameter, a path segment). UseencodeURI()only on a whole URL you're tidying up, and almost never. In a query string a space may be+or%20, but in a path it must be%20. Encode exactly once, as the last step before the URL is assembled.
What each function leaves alone
Both functions percent-encode UTF-8 bytes. They differ in which ASCII characters they treat as structure and leave untouched. I ran these in Node 22:
| Input | encodeURIComponent | encodeURI |
|---|---|---|
a b&c=d/é?😀 | a%20b%26c%3Dd%2F%C3%A9%3F%F0%9F%98%80 | a%20b&c=d/%C3%A9?%F0%9F%98%80 |
https://x.com/a b?q=a&b=c#h é | everything, including : and / | https://x.com/a%20b?q=a&b=c#h%20%C3%A9 |
encodeURI keeps ; , / ? : @ & = + $ # because they're meaningful in a URL. That's exactly why it's wrong for a value: &, =, / and ? in your data pass straight through and change the URL's structure. encodeURIComponent escapes all of them. Neither escapes A-Z a-z 0-9 - _ . ! ~ * ' ( ), so an apostrophe or parenthesis in a value survives (it's (ok)*!~ stays it's%20(ok)*!~). That's valid, but it bites if you later put the URL inside a single-quoted shell string or an HTML attribute.
The rule I actually follow
// values go through encodeURIComponent, structure is typed by you
const url =
"https://api.example.com/search?q=" + encodeURIComponent(query) +
"&lang=" + encodeURIComponent(lang);
// better: let the platform do it
const url2 = new URL("https://api.example.com/search");
url2.searchParams.set("q", query);
url2.searchParams.set("lang", lang);encodeURI is for the case where someone hands you a mostly-valid URL with raw spaces or non-ASCII characters in it and you want it to be sendable. Even then, new URL(str).href does the same job and understands the parts. It turned https://x.com/a b?q=a b into https://x.com/a%20b?q=a%20b.
The + vs %20 trap
Here's the one that causes silent data corruption. Two different specs are in play:
- RFC 3986 (generic URI syntax) knows only percent-encoding. A space is
%20. A literal+is just a plus sign. application/x-www-form-urlencoded(HTML forms, defined by the WHATWG URL standard) encodes a space as+, and a literal plus as%2B.
Query strings in practice follow the form rules, because that's what browsers produce and most servers parse. Paths don't. Watch what the same string does in each:
| Where you decode | a+b%2Bc becomes |
|---|---|
new URLSearchParams("q=a+b%2Bc").get("q") | a b+c |
decodeURIComponent("a+b%2Bc") | a+b+c |
URLSearchParams treats the first + as a space. decodeURIComponent doesn't know about that convention and leaves it as a plus. So if you decode a query string by hand with decodeURIComponent, every space that arrived as + stays a +. And going the other way, URLSearchParams serializes the value a b+c as a+b%2Bc, while encodeURIComponent gives a%20b%2Bc. Both are valid for a query; only one is valid for a path.
Gotcha: a
+in a path segment is a literal plus./files/a+b.pdfis the filea+b.pdf, nota b.pdf. Use%20there, always.
The practical consequence is for emails and phone numbers in query strings. A user whose address is jane+news@example.com sends jane+news@example.com unencoded, the server applies form decoding, and the mail goes to jane news@example.com. Encode the value with encodeURIComponent (%2B) and both decoders agree.
Double-encoding, the other classic
Percent signs are themselves encoded, so encoding twice turns %41 into %2541:
encodeURIComponent("%41"); // "%2541"
decodeURIComponent("%2541"); // "%41" — one layer off, not "A"It usually happens when a value is encoded in one layer (a helper, an HTTP client) and again by you. The telltale sign in logs is %25 showing up right before two hex digits: %2520 is an encoded %20, i.e. a space that went through the wringer twice. The fix is to find the layer that's already doing it, not to add a decode.
The reverse also exists: a redirect parameter like ?next=/a?b=1&c=2 without encoding, where &c=2 becomes a top-level parameter of your URL and next gets truncated. This is how some open-redirect and signature-mismatch bugs begin, so encode the value, then validate the decoded result.
Three mistakes that look like they work
- Encoding the entire URL with
encodeURIComponent. You gethttps%3A%2F%2Fx.com%2F..., which is fine as a value inside another URL's query and wrong as a URL. - Calling
decodeURIComponenton untrusted input without a try/catch. A truncated sequence like%E0%A4%AthrowsURIError: URI malformed, and so doesencodeURIComponenton a lone surrogate such as"\ud800". A bad link shouldn't be able to crash your request handler. - Hand-rolling a replace chain.
str.replace(" ", "%20")replaces only the first space, and it ignores non-ASCII bytes entirely. Use the built-ins.
When I need to see what a URL really contains, I paste it into the URL encoder/decoder to check each layer, or into the URL parser to see which part a character landed in. Seeing %2520 split out is faster than counting percent signs by eye.
FAQ
Should I use encodeURI or encodeURIComponent?
encodeURIComponent for any piece of data going into a URL. encodeURI only when you're normalizing a complete URL and know the structure is already right.
Is a space + or %20?
%20 is safe everywhere. + means a space only inside a form-encoded query string, so use it only if you're deliberately producing form encoding, and never in a path.
Does encoding handle emoji and accented characters?
Yes. Both functions convert to UTF-8 first, so é becomes %C3%A9 and 😀 becomes %F0%9F%98%80. Make sure the receiving side decodes as UTF-8, not Latin-1, or you'll see é instead.
Do I need to encode the fragment?
Rarely on the server side, since it never leaves the browser, but yes if client code reads it and may contain & or =. The same rules apply.
