URL decode in JavaScript

decodeURIComponent(text)

decodeURIComponent(text) turns every %XX escape back into the bytes it stands for and reads them as UTF-8. It’s the inverse of encodeURIComponent, and it decodes everything, including %26 (&), %2F (/) and %3F (?).

decodeURIComponent('Tom%20%26%20Jerry%2F50%25%20off%3F'); // 'Tom & Jerry/50% off?'
decodeURIComponent('caf%C3%A9');                          // 'café'

A + is a plus unless it’s form data

decodeURIComponent leaves + alone, because in a URL a plus is a plus. Only application/x-www-form-urlencoded data, which HTML forms and URLSearchParams write, uses + for a space:

const text = 'Tom+%26+Jerry%2F50%25+off%3F';
decodeURIComponent(text);                     // 'Tom+&+Jerry/50%+off?'
decodeURIComponent(text.replace(/\+/g, ' ')); // 'Tom & Jerry/50% off?'
new URLSearchParams('q=' + text).get('q');    // 'Tom & Jerry/50% off?'

Replace the pluses before decoding, never after, or an encoded plus (%2B) turns into a space too. The tool below shows that text both ways: Decoded keeps the +, As a form sends it reads it as a space.

It throws on a bad escape

A % that isn’t followed by two hex digits, or escapes that don’t add up to valid UTF-8, make decodeURIComponent throw:

decodeURIComponent('100%');   // URIError: URI malformed
decodeURIComponent('%E0%A4'); // URIError: URI malformed (a 3-byte character cut short)

User-typed text and half-encoded links hit this. Catch it where you decode:

const safeDecode = (s) => { try { return decodeURIComponent(s); } catch { return s; } };

URLSearchParams never throws: it keeps a stray % as written and turns bytes that aren’t UTF-8 into � (U+FFFD). The tool below names the bad escape and its position instead.

decodeURI leaves reserved escapes

decodeURI is the inverse of encodeURI, so it won’t decode an escape for a character that shapes a URL: decodeURI('Tom%20%26%20Jerry%2F50%25%20off%3F') gives 'Tom %26 Jerry%2F50% off%3F'. Use it for display, not for reading values.

Read query strings with URL

const url = new URL('https://example.com/search?q=caf%C3%A9+%26+cake&tag=a&tag=b&empty=');
url.searchParams.get('q');        // 'café & cake'
url.searchParams.getAll('tag');   // ['a', 'b']
url.searchParams.get('empty');    // ''
url.searchParams.get('missing');  // null
Object.fromEntries(url.searchParams); // { q: 'café & cake', tag: 'b', empty: '' }: the last tag wins

Decode once. 'a%2520b' means a%20b, but run through decodeURIComponent twice it becomes 'a b'.

Try it in the URL Encoder & Decoder

Decoded

Every escape read as bytes, with a + left as a plus.

As a form sends it

The same text with + read as a space, which is how an HTML form posts a query string.

Runs in your browser. Nothing you enter is sent anywhere.