Decode HTML Entities in JavaScript: DOMParser, textarea and Node Without innerHTML Risks

JavaScript has no built-in `decodeHTMLEntities()` function. In a browser you borrow the HTML parser, and the safe ways to do that are `DOMParser` or a `<textarea>` element. In Node.js there is no parser at all, so you use a library or a deliberately small decoder. This article shows each approach with tested output, explains why the popular `div.innerHTML` trick can execute code, and covers the details that make decoded text look subtly wrong, such as `&nbsp;` and double encoding.

Why the browser does not decode entities for you

Entities are part of HTML syntax, not of JavaScript strings. When the browser parses a page, &amp; in the markup becomes & in the DOM, so element.textContent already gives you decoded text. You only need to decode by hand when the encoded text arrives some other way: from an API response, a data attribute, an RSS feed, a CMS field or a WebView message. Then the string still contains &amp;, and setting it as textContent shows the entity literally, which is correct and safe, but not what you want.

The goal is to run the HTML parser's entity rules on a string without letting that string become HTML. Every safe method below is a way of doing exactly that.

DOMParser: the standard way in the browser

DOMParser parses a string into a separate, inert document. Scripts in it do not run and images do not load, so reading its text back is safe:

function decodeEntities(str) {
  const doc = new DOMParser().parseFromString(str, "text/html");
  return doc.documentElement.textContent;
}

decodeEntities("Tom &amp; Jerry &lt;b&gt; &quot;hi&quot; &#x1F600; &copy;");
// 'Tom & Jerry <b> "hi" πŸ˜€ Β©'

It handles all 2,125 named references in the HTML standard, decimal and hex numeric references, and the legacy forms without a semicolon, exactly as the page itself would. One effect to know: if the input contains real tags rather than encoded ones, they are parsed as elements and only their text survives, so <b>bold</b> comes back as bold. For entity decoding that is usually what you want; if the input should never contain markup, treat surviving < characters as a red flag instead.

The textarea trick, and why not a div

Older answers on the web use a <textarea>. Its content is parsed as plain text with entities, so tags never become elements:

function decodeEntities(str) {
  const ta = document.createElement("textarea");
  ta.innerHTML = str;
  return ta.value;
}

This gives the same result as DOMParser and works in very old browsers. The version that swaps the textarea for a div is the one to avoid. A div created by document belongs to the live page, and assigning innerHTML creates real elements immediately, even before the element is attached. With input like <img src=x onerror=alert(1)> the image starts loading, fails and runs the handler: a cross-site scripting bug hidden inside a helper called "decode". MDN's innerHTML page warns about exactly this. With DOMParser the same input simply produces an empty string.

Node.js: he, entities, or no dependency

Node has no DOM, so DOMParser and document do not exist. The two libraries that implement the full HTML standard table are he and entities; the latter is what htmlparser2 and cheerio use internally:

import he from "he";
import { decodeHTML } from "entities";

he.decode("Tom &amp; Jerry &#x1F600; &copy;");     // 'Tom & Jerry πŸ˜€ Β©'
decodeHTML("Tom &amp; Jerry &#x1F600; &copy;");    // 'Tom & Jerry πŸ˜€ Β©'

If you cannot add a dependency, for example in a Scriptable widget, a Cloudflare Worker or a small WebView bridge, a short decoder covers the cases that really occur in API data: the five XML entities, &nbsp; and numeric references. Anything else is left as it is rather than guessed:

const NAMED = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0" };

function decodeBasicEntities(str) {
  return str.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z]+);/g, (match, body) => {
    if (body[0] !== "#") return NAMED[body] ?? match;   // names are case-sensitive
    const hex = body[1] === "x" || body[1] === "X";
    const cp = parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
    return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : match;
  });
}

decodeBasicEntities("Fish &amp; Chips &#8211; &#x1F600; &hellip;");
// 'Fish & Chips – πŸ˜€ &hellip;'   (unknown names are kept)

The replacement runs once over the string, so &amp;lt; correctly becomes &lt; and not <. Use String.fromCodePoint, not fromCharCode, or emoji and other characters above U+FFFF break.

&nbsp; is not a space

&nbsp; decodes to U+00A0, the no-break space, not to an ordinary space (U+0020). It looks identical on screen, but str === "a b" is false, split(" ") does not split on it, and some databases and search indexes treat it as a letter. A frequent request is therefore "decode, then turn non-breaking spaces into normal ones":

const text = decodeEntities(html).replace(/\u00a0/g, " ");

// or normalise every kind of Unicode space at once
const clean = decodeEntities(html).replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");

Note that the regular-expression \s class already matches U+00A0 in JavaScript, so trim() and /\s+/ work; it is equality checks and plain-string split that catch people out.

Double encoding and other surprises

Text that shows &amp;amp; or &amp;lt; after one decode was encoded twice, typically by a template that escapes a value which had already been escaped. Decoding twice "fixes" the display, but it also turns a user's literal &lt; into <. Find the layer that escapes twice and remove one; do not stack decoders.

Legacy references without a semicolon are another surprise. For compatibility, HTML still recognises 106 old names such as &copy and &not without the ;, so a full decoder turns the made-up text &notit; into Β¬it;. DOMParser, he and entities all do this, because the HTML standard requires it inside text. The basic decoder above does not, which is often more predictable for API data.

Finally, decoding is the opposite of escaping. After you decode, the result is plain text; if you then insert it into the page, use textContent or let your framework escape it. Setting innerHTML with decoded text, or using React's dangerouslySetInnerHTML, reopens the XSS hole. Paste a sample into the HTML entities decoder to see exactly what a string decodes to before writing code for it.

Sources and further reading

The WHATWG HTML Standard defines the named character references (2,231 entries in its entities.json, 106 of them legacy forms without a semicolon) and the character reference state that decides how text is decoded. MDN documents DOMParser.parseFromString(), Element.innerHTML and its security considerations, and String.fromCodePoint(). The he and entities package documentation describes their decode options, including strict mode.

Frequently asked questions

How do I decode HTML entities in JavaScript?

In a browser, use new DOMParser().parseFromString(str, "text/html").documentElement.textContent. It decodes every named and numeric entity exactly like the page parser and does not run scripts in the input.

Is decoding with div.innerHTML safe?

No. Assigning innerHTML on an element of the live document creates real elements, so an img tag with an onerror handler in the input runs code. Use DOMParser or a textarea instead.

How do I decode HTML entities in Node.js?

Node has no DOM, so use the he or entities package, both of which implement the full HTML standard table. Without dependencies, a small regex decoder for the XML entities, &nbsp; and numeric references covers most API data.

Why does &nbsp; not equal a space after decoding?

&nbsp; decodes to U+00A0, the no-break space, which is a different character from the normal space U+0020. Replace it with /\u00a0/g if you need ordinary spaces.

Why do I still see &amp; after decoding?

The text was encoded twice, so one decode turns &amp;amp; into &amp;. Fix the code that escapes twice rather than decoding twice, otherwise legitimate literal entities in user text get decoded too.

Does textContent decode HTML entities?

Reading textContent from parsed HTML gives decoded text, because the parser already decoded it. Setting textContent to a string containing &amp; shows the entity literally, which is safe but not decoded.