innerHTML in JavaScript: read, set and use it safely

innerHTML turns a string into real elements inside another element. It is quick and handy, and it has three traps worth knowing before you build on it.

element.innerHTML is a property that holds the HTML inside an element as a string. Read it and you get the markup. Set it and the browser parses your string, removes everything that was inside, and puts the new elements in their place.

const box = document.getElementById('box');
box.innerHTML = 'Hello <b>world</b>';   // world is bold
console.log(box.innerHTML);             // "Hello <b>world</b>"

Try it with your own text. The left box sets innerHTML, the right box sets textContent to the same string.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>innerHTML vs textContent</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { font-size: 13px; font-weight: 600; }
  input { box-sizing: border-box; width: 100%; margin: 6px 0 14px; padding: 9px 10px;
          font: 15px ui-monospace, Consolas, monospace; border: 1px solid #c9ced8; border-radius: 8px; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  @media (max-width: 480px) { .row { grid-template-columns: 1fr; } }
  .box { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  .box h3 { margin: 0 0 8px; font: 700 13px ui-monospace, Consolas, monospace; color: #475569; }
  .out { min-height: 24px; font-size: 16px; overflow-wrap: anywhere; }
  .read { margin-top: 8px; font: 12px ui-monospace, Consolas, monospace; color: #64748b; overflow-wrap: anywhere; }
</style>
</head>
<body>
<label for="src">Type any text with tags:</label>
<input id="src" value="Hello <b>world</b> &amp; <i>friends</i>">

<div class="row">
  <div class="box">
    <h3>el.innerHTML = text</h3>
    <div class="out" id="asHtml"></div>
    <div class="read" id="readHtml"></div>
  </div>
  <div class="box">
    <h3>el.textContent = text</h3>
    <div class="out" id="asText"></div>
    <div class="read" id="readText"></div>
  </div>
</div>

<script>
  const src = document.getElementById('src');
  const asHtml = document.getElementById('asHtml');
  const asText = document.getElementById('asText');

  function show() {
    asHtml.innerHTML = src.value;    // parsed as HTML: tags become elements
    asText.textContent = src.value;  // shown as plain text: tags stay visible

    // Reading innerHTML back shows the markup each box now holds
    document.getElementById('readHtml').textContent = 'innerHTML reads: ' + asHtml.innerHTML;
    document.getElementById('readText').textContent = 'innerHTML reads: ' + asText.innerHTML;
  }

  src.addEventListener('input', show);
  show();
</script>
</body>
</html>
The same string set two ways. Type tags and watch the left box render them while the right one shows them as text.

The small grey line under each box reads innerHTML back. On the right, the tags come back as &lt; and &gt;, because that box holds text, not elements.

Reading innerHTML versus setting it

Reading does not hand you the string you set. The browser builds a fresh string from the elements that exist now. That is why & reads back as &amp;, and why an unclosed tag you set comes back closed.

Setting is a full replacement. The old children are removed, the string is parsed, and the result becomes the new content. Anything you attached to the old children, such as event listeners, is gone with them.

innerHTML vs textContent vs innerText

The three properties look alike and behave very differently. Here is the same paragraph read three ways:

innerHTML returns markup, textContent returns all text, innerText returns the text you can see.
innerHTML returns markup, textContent returns all text, innerText returns the text you can see.
Property Reading gives Setting a string with tags Use it for
innerHTML The markup inside, as a string Tags become elements HTML you wrote yourself
textContent All text, hidden text included Tags show as plain characters Plain text, and anything users typed
innerText Only the text as rendered on screen Tags show as characters, line breaks become <br> Copying what the user sees
outerHTML The element itself plus its inside Replaces the element itself Swapping out a whole element

innerText has to know how the page is laid out to decide what is visible, so reading it can make the browser calculate layout. For setting plain text, textContent is the simpler choice.

Why innerHTML += breaks your page

list.innerHTML += '<li>New</li>' looks like it adds one item. It is shorthand for list.innerHTML = list.innerHTML + '<li>New</li>'. The browser turns every existing child into a string, adds yours, then parses the whole thing again.

+= rebuilds every child from a string. insertAdjacentHTML adds the new piece and leaves the rest alone.
+= rebuilds every child from a string. insertAdjacentHTML adds the new piece and leaves the rest alone.

The rebuilt children look identical but are new elements. Two things do not survive the trip through a string:

  • Event listeners added with addEventListener belong to the old elements.
  • Typed values in inputs live in the element, not in the markup, so a field returns empty (or to its value attribute).

Try both sides. Type in the boxes, click the counters, then add an item.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The innerHTML += trap</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  p.how { margin: 0 0 12px; font-size: 14px; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  @media (max-width: 480px) { .row { grid-template-columns: 1fr; } }
  .panel { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  .panel.bad { border-top: 4px solid #ea580c; }
  .panel.good { border-top: 4px solid #16a34a; }
  h3 { margin: 0 0 8px; font: 700 13px ui-monospace, Consolas, monospace; }
  ul { list-style: none; margin: 0 0 10px; padding: 0; }
  li { padding: 6px 0; border-bottom: 1px solid #eef0f3; font-size: 14px; }
  li input { width: 100%; box-sizing: border-box; padding: 6px 8px; font-size: 14px; border: 1px solid #c9ced8; border-radius: 6px; }
  button { font: 600 14px system-ui, sans-serif; padding: 7px 12px; border-radius: 8px; border: 1px solid #c9ced8; background: #fff; cursor: pointer; }
  button.add { background: #1d2330; color: #fff; border-color: #1d2330; }
</style>
</head>
<body>
<p class="how">1. Type in both boxes. 2. Click both "Clicked" buttons. 3. Press "Add item" on each side.</p>

<div class="row">
  <div class="panel bad">
    <h3>list.innerHTML += ...</h3>
    <ul id="listA">
      <li><input placeholder="Type here"></li>
      <li><button class="count">Clicked 0</button></li>
    </ul>
    <button class="add" id="addA">Add item</button>
  </div>
  <div class="panel good">
    <h3>list.insertAdjacentHTML(...)</h3>
    <ul id="listB">
      <li><input placeholder="Type here"></li>
      <li><button class="count">Clicked 0</button></li>
    </ul>
    <button class="add" id="addB">Add item</button>
  </div>
</div>

<script>
  // Give each "Clicked" button a listener that counts clicks
  document.querySelectorAll('.count').forEach((btn) => {
    let n = 0;
    btn.addEventListener('click', () => { n++; btn.textContent = 'Clicked ' + n; });
  });

  let a = 0, b = 0;

  // Rebuilds the whole list from a string: new input, new button, no listener
  document.getElementById('addA').addEventListener('click', () => {
    document.getElementById('listA').innerHTML += '<li>New item ' + (++a) + '</li>';
  });

  // Parses only the new piece; the existing nodes are left alone
  document.getElementById('addB').addEventListener('click', () => {
    document.getElementById('listB').insertAdjacentHTML('beforeend', '<li>New item ' + (++b) + '</li>');
  });
</script>
</body>
</html>
Left: innerHTML += wipes the typed text and the counter stops counting. Right: insertAdjacentHTML keeps both.

On the left, the counter still shows its last number, because that number was text in the markup. Clicking it does nothing, because the listener was on the old button.

Adding HTML without rewriting: insertAdjacentHTML and append

insertAdjacentHTML(position, html) parses only the string you give it and inserts the result at one of four places:

el.insertAdjacentHTML('beforebegin', html);  // before el
el.insertAdjacentHTML('afterbegin', html);   // inside el, first
el.insertAdjacentHTML('beforeend', html);    // inside el, last
el.insertAdjacentHTML('afterend', html);     // after el

If you would rather not write HTML strings at all, build elements and add them with append:

const li = document.createElement('li');
li.textContent = 'New item';
list.append(li);

This is longer, but you get the element back as a variable. You can attach a listener to it on the next line, and text you put in with textContent can never turn into tags.

Script tags inserted with innerHTML do not run

A <script> inside a string you assign to innerHTML becomes an element in the page, and it never executes. This is how the HTML standard defines it, not a bug in your code.

When a script really has to be added from JavaScript, create the element yourself:

const s = document.createElement('script');
s.textContent = 'console.log("this runs")';
document.body.append(s);

Usually, though, the simpler fix is to put the code in your main script and call it after inserting the HTML. If the whole page is not running its scripts, see HTML JavaScript not working.

Never put untrusted text into innerHTML

"No scripts run" does not make innerHTML safe. Inline event handler attributes on the inserted elements can still run code.

If a comment, a name or a URL parameter goes straight into innerHTML, the person who wrote that text gets to write part of your page. This is called cross-site scripting (XSS).

The same visitor text through innerHTML and through textContent.
The same visitor text through innerHTML and through textContent.

The rule is short. Text from users, from a form, from the address bar or from another site goes in with textContent. Keep innerHTML for strings you wrote yourself.

A finished example: a to-do list from an array

Real pages often keep data in an array and draw the list from it. This one builds each row with createElement and puts the task text in with textContent. The second task is <b>bold</b>, and it stays plain text.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>To-do list rendered safely</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .app { max-width: 460px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
  h2 { margin: 0 0 12px; font-size: 18px; }
  form { display: flex; gap: 8px; margin-bottom: 12px; }
  form input { flex: 1; min-width: 0; padding: 9px 10px; font-size: 15px; border: 1px solid #c9ced8; border-radius: 8px; }
  form button { padding: 9px 14px; font: 600 15px system-ui, sans-serif; border: 0; border-radius: 8px; background: #16a34a; color: #fff; cursor: pointer; }
  ul { list-style: none; margin: 0; padding: 0; }
  li { display: flex; align-items: center; gap: 10px; padding: 9px 2px; border-bottom: 1px solid #eef0f3; }
  li span { flex: 1; overflow-wrap: anywhere; }
  li.done span { text-decoration: line-through; color: #94a3b8; }
  li button { border: 0; background: none; color: #94a3b8; font-size: 18px; cursor: pointer; }
  .count { margin-top: 10px; font-size: 13px; color: #64748b; }
</style>
</head>
<body>
<div class="app">
  <h2>To-do</h2>
  <form id="form">
    <input id="text" placeholder="Add a task, tags and all" autocomplete="off">
    <button>Add</button>
  </form>
  <ul id="list"></ul>
  <div class="count" id="count"></div>
</div>

<script>
  // The data lives in an array; the list is always drawn from it
  const todos = [
    { text: 'Buy milk', done: false },
    { text: '<b>bold</b> stays as text', done: false },
    { text: 'Read about innerHTML', done: true },
  ];
  const list = document.getElementById('list');

  function render() {
    list.replaceChildren();  // empty the list
    todos.forEach((todo, i) => {
      const li = document.createElement('li');
      li.className = todo.done ? 'done' : '';

      const box = document.createElement('input');
      box.type = 'checkbox';
      box.checked = todo.done;
      box.dataset.i = i;

      const span = document.createElement('span');
      span.textContent = todo.text;  // user text goes in as text, never as HTML

      const del = document.createElement('button');
      del.textContent = '×';
      del.setAttribute('aria-label', 'Delete');
      del.dataset.i = i;

      li.append(box, span, del);
      list.append(li);
    });
    const left = todos.filter((t) => !t.done).length;
    document.getElementById('count').textContent = left + ' of ' + todos.length + ' left';
  }

  // One listener on the list handles every checkbox and delete button
  list.addEventListener('click', (e) => {
    const i = e.target.dataset.i;
    if (i === undefined) return;
    if (e.target.type === 'checkbox') todos[i].done = e.target.checked;
    else todos.splice(i, 1);
    render();
  });

  document.getElementById('form').addEventListener('submit', (e) => {
    e.preventDefault();  // stay on the page
    const input = document.getElementById('text');
    const text = input.value.trim();
    if (!text) return;
    todos.push({ text, done: false });
    input.value = '';
    render();
  });

  render();
</script>
</body>
</html>
Add a task with tags in it. It shows as typed. Tick and delete work because the list is drawn from the array each time.
  • One source of truth: the todos array. Every change edits the array, then calls render().
  • One listener: clicks are handled on the <ul>, so rows can be rebuilt freely without re-adding listeners.
  • Form stays put: the submit handler calls preventDefault(), so the page does not reload.

If you prefer template literals, escape each value before it goes into the string:

const esc = (s) => String(s)
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');

list.innerHTML = todos.map((t) => `<li>${esc(t.text)}</li>`).join('');

Replacing the whole list in one assignment like this is fine. It is the repeated += on content with listeners and inputs that hurts. For a longer walk-through of a list app in one file, see the simple HTML todo list.

When it does not work

What you see (Chrome wording) Cause Fix
"Cannot set properties of null (setting 'innerHTML')" The script ran before the element existed, or the id is wrong Put the script at the end of <body> or add defer; check the id spelling
A <script> you inserted does nothing Scripts inserted with innerHTML are never run Create it with createElement('script') and append it
Buttons stop responding after adding an item innerHTML += replaced them with copies that have no listeners Use insertAdjacentHTML or append, or one listener on the parent
Typed text in inputs disappears innerHTML += rebuilt the inputs from markup Same fix: stop rewriting existing content
Tags appear as <b> on screen You set textContent or innerText Use innerHTML for HTML you wrote
Tags vanish and only the words show You read textContent or innerText Read innerHTML to get the markup
A visitor's text shows up bold, linked or styled User text went into innerHTML Use textContent, or escape it first. Treat it as a security bug

For the first row, the defer version looks like this:

<script src="app.js" defer></script>

The quickest way to show someone what += does to a list is to let them click it. A screenshot cannot be typed into, and an .html file sent as an attachment may open as plain code on a phone.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can type, click and add items themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between innerHTML and textContent?

innerHTML parses the string as HTML, so <b>hi</b> becomes bold text. textContent treats the string as plain text, so the same value shows the tags as characters. Reading them differs too: innerHTML returns the markup inside the element, textContent returns only the text.

Why is my innerHTML not working?

The most common cause is that the element is null: the script runs before the element exists, or the id is misspelled. The console shows an error such as "Cannot set properties of null". Move the script below the element, add defer, or check the id.

Why does a script I add with innerHTML not run?

The HTML standard says script elements inserted through innerHTML are not executed. Create the script with document.createElement('script'), set its text or src, and append it. That script does run.

Is innerHTML bad practice?

No. It is a fine way to insert HTML you wrote yourself. It becomes a problem in two cases: when the string contains text from users or other outside sources, and when += is used to add to content that has listeners or form input.

How do I add HTML without replacing what is already there?

Use insertAdjacentHTML('beforeend', html) to parse and add a piece of HTML, or append() with elements made by createElement. Both leave the existing children untouched.

Keep reading