Building new content in JavaScript means three steps: make an element, fill it, and put it in the right place in the page. The filling part is easy. The putting part has more options than most people realise, and choosing badly is how you end up with nodes in the wrong order, duplicated items, or a page that throws away its event listeners every time it refreshes. This page maps out the whole toolkit.
How do I create an element and fill it with content?
document.createElement("tagName") returns a brand new element that belongs to no document yet. It is inert: it exists in memory, it is not in the page, and nothing about it can be seen or clicked. You give it a class, some attributes and some children, and only when you insert it does it become part of the document.
Fill it with textContent for text and by appending more elements for structure. Building a node before inserting it, as opposed to inserting an empty node and then filling it, keeps the number of separate operations down and makes the code easier to read.
<section id="catalog" class="grid">
<article class="card" data-sku="A-100"><h3>Desk lamp</h3><p class="price">$24</p></article>
<article class="card" data-sku="B-220"><h3>Monitor stand</h3><p class="price">$41</p></article>
</section>
<aside id="promo" class="promo"><p>Free delivery over $30</p></aside>
<ul id="tags"><li>new</li></ul>
<div id="trash"><span>one</span><span>two</span><span>three</span>&div>
<div id="tray"><article class="card" data-sku="D-440"><h3>Desk shelf</h3></article></div>
// Build a card node before it ever touches the page
const card = document.createElement("article");
card.className = "card card--new";
card.dataset.sku = "C-331";
const title = document.createElement("h3");
title.textContent = "Cable organiser";
const price = document.createElement("p");
price.className = "price";
price.textContent = "$9";
card.append(title, price);
console.log(card.outerHTML);
// <article class="card card--new" data-sku="C-331"><h3>Cable organiser</h3><p class="price">$9</p></article>
console.log(document.querySelectorAll("#catalog .card").length); // 2 — the new card is not in the page yet
Note that append accepts several arguments and adds them in order. It also accepts plain strings, and a string argument is always inserted as a text node, never parsed as markup — so you can safely append a user-supplied label this way. When you want that text node explicitly, document.createTextNode("hello") produces one you can mix with elements in the same call.
Which method decides where a new node lands?
There are five position methods, and they split cleanly by where they put the node relative to the element you call them on. append and prepend add children inside the element, at the end or the start. before and after add siblings next to the element. replaceWith puts the new node where the old one was and removes the old one.
const catalog = document.querySelector("#catalog");
const firstCard = catalog.querySelector(".card");
// append and prepend add children inside an element
const note = document.createElement("p");
note.className = "note";
note.textContent = "Prices include VAT";
catalog.append(note); // last child
catalog.prepend(note); // first child — the node moves, it is not copied
console.log(catalog.firstElementChild.className); // "note"
// before and after need a reference node to sit beside
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = "New";
firstCard.before(badge);
console.log(catalog.children[1].className); // "badge"
// replaceWith swaps a node out entirely
const old = catalog.querySelector(".note");
const replacement = document.createElement("p");
replacement.className = "note note--final";
old.replaceWith(replacement);
console.log(catalog.querySelector(".note").className); // "note note--final"
Every one of these methods returns the node it inserted, so they can be chained. They also all accept multiple arguments and mix of nodes and strings, which removes most of the reasons to reach for insertAdjacentHTML.
When is insertAdjacentHTML still the right tool?
insertAdjacentHTML(position, html) parses a string of markup and inserts it at one of four named positions: beforebegin and afterbegin around the start, beforeend and afterend around the end. The two beginning positions put the content outside the element, before its first child; the two ending positions put it inside, after its last child.
Its sibling insertAdjacentText takes the same positions but inserts the string as plain text, so nothing in it is ever parsed. That makes it the safe way to add a sentence next to an element without risking markup injection.
const promo = document.querySelector("#promo");
// insertAdjacentHTML parses a string of markup
promo.insertAdjacentHTML("beforeend", "<strong>Order by Friday</strong>");
console.log(promo.querySelector("strong").textContent); // "Order by Friday"
// insertAdjacentText never parses anything
promo.insertAdjacentText("afterbegin", "Delivery: ");
console.log(promo.firstChild.textContent); // "Delivery: "
// The remaining two positions sit outside the element
promo.insertAdjacentHTML("beforebegin", "<p class='lead'>Before</p>");
promo.insertAdjacentHTML("afterend", "<p class='trail'>After</p>");
console.log(promo.previousElementSibling.className); // "lead"
console.log(promo.nextElementSibling.className); // "trail"
Why move a batch of nodes through a DocumentFragment?
Every time you insert a node the browser may have to recalculate layout and paint. Insert ten nodes one at a time and you give it ten chances to do that work. A DocumentFragment is a lightweight container that is not part of the document: you build your nodes inside it off-screen, then insert the whole fragment in a single operation. The browser performs one layout pass instead of ten, and the fragment empties itself as it goes.
const catalog = document.querySelector("#catalog");
// A fragment is a temporary holding area — nothing is in the document yet
const fragment = document.createDocumentFragment();
for (const item of [
{ name: "Mouse mat", price: "$6" },
{ name: "Desk cable clip", price: "$3" },
]) {
const card = document.createElement("article");
card.className = "card";
const title = document.createElement("h3");
title.textContent = item.name;
card.append(title);
fragment.append(card);
}
console.log(fragment.childNodes.length); // 2 — parked off-document
catalog.append(fragment);
console.log(catalog.querySelectorAll(".card").length); // 4
console.log(fragment.childNodes.length); // 0 — the fragment emptied itself on insert
The same trick applies to a long list of text nodes. document.createTextNode("hello") produces a text node you can insert alongside elements, which append would otherwise turn into a string for you.
How do you remove elements without breaking the page?
element.remove() detaches the element it is called on and everything inside it. element.replaceChildren() empties a container in one step, and passing it new nodes replaces the contents with them. The older trick of assigning an empty string to innerHTML does the same thing, and it works, but it works by parsing a string, which is slower and easy to get wrong with quoting.
What matters more than the method is the side effect. Any listener you attached with addEventListener to a removed node is gone with it, and any state that lived only in that node's markup goes too. Removing a node and re-adding an identical copy is not a no-op — the new element is a different object with different listeners.
const trash = document.querySelector("#trash");
// remove() detaches the node it is called on
trash.children[1].remove();
console.log(trash.children.length); // 2
// replaceChildren() empties a container in one step
trash.replaceChildren();
console.log(trash.children.length); // 0
// The older innerHTML trick works too
const promo = document.querySelector("#promo");
promo.innerHTML = "";
console.log(promo.children.length); // 0
Removing nodes while you iterate over them is safe, as long as the collection you are looping through is a static NodeList from querySelectorAll. The list is a snapshot, so removing the current element does not disturb the remaining entries. A live HTMLCollection behaves differently, which is one more reason to avoid the plural getElementsBy... methods.
What happens when you insert a node that already has a parent?
It moves. The DOM does not have a concept of a node being in two places at once, so inserting an element that is already in the document takes it out of its old position and puts it in the new one. If you want a second copy, you have to ask for one explicitly with cloneNode.
const tray = document.querySelector("#tray");
const card = document.createElement("article");
card.className = "card";
card.textContent = "Portable SSD";
tray.append(card);
console.log(tray.querySelectorAll(".card").length); // 2
// Inserting it again MOVES it — the same node, not a copy
const shelf = document.createElement("div");
document.body.append(shelf);
shelf.append(card);
console.log(tray.querySelectorAll(".card").length); // 1
console.log(shelf.querySelectorAll(".card").length); // 1
console.log(card.textContent); // "Portable SSD" — the same element, not a clone
// cloneNode(true) gives you a genuine copy
const duplicate = card.cloneNode(true);
tray.append(duplicate);
console.log(tray.querySelectorAll(".card").length); // 2
One caveat about clones: cloneNode(true) copies the markup and the attribute values, but it does not copy event listeners added with addEventListener. A cloned button looks identical and does nothing when clicked, which is a confusing bug if you have not seen it before.
The insertion and removal reference
document.createElement(tag)— build an element that is not yet in the page.parent.append(...nodes)andparent.prepend(...nodes)— add children inside an element, at the end or the start.ref.before(node),ref.after(node),ref.replaceWith(node)— add or swap siblings next to an existing node.el.insertAdjacentHTML(position, markup)andel.insertAdjacentText(position, text)— insert atbeforebegin,afterbegin,beforeendorafterend.document.createDocumentFragment()— batch up nodes off-document and insert them together.el.remove(),el.replaceChildren(...nodes)— detach a node, or empty a container.el.cloneNode(true)— copy an element and its descendants, without copying listeners.
Try it yourself: make an empty page with a <ul id="menu"> in it and a script tag below it. In the console, create three list items with document.createElement("li"), set each one's textContent to a different word, and append them with menu.append(...items). Now call menu.append(items[0]) again and watch the first item jump to the end. Finally, run menu.replaceChildren() and confirm the list empties in one call.