So far you have changed elements that already exist in the HTML. Most interfaces also need to create elements: one list item per course, one row per result, a message that appears and disappears. This lesson shows how to build elements safely from data, insert them in the right place, and remove them again. You will use it to render the course dashboard for the first time.
Creating an element
document.createElement makes a new element that is not yet on the page. Set its content and attributes, then insert it:
const message = document.createElement('p');
message.textContent = 'Your changes were saved.';
message.className = 'notice';
document.querySelector('main').append(message);
Until you call append (or another insertion method), the element exists only in memory. Building it fully before inserting means the browser updates the page once, not once per change.
As lesson 15 explained, use textContent for any text that comes from data. Building elements with createElement and textContent keeps data as data: even a course title containing <img src=x onerror=alert(1)> is shown as harmless text.
Inserting in the right place
Modern DOM methods describe exactly where a node goes. All of them accept elements and plain strings, and several at once:
| Method | Inserts |
|---|---|
parent.append(a, b) | As the last children of parent |
parent.prepend(a) | As the first children of parent |
el.before(a) | Just before el, as a sibling |
el.after(a) | Just after el, as a sibling |
el.replaceWith(a) | In place of el, removing el |
parent.replaceChildren(a, b) | Instead of all current children of parent |
const list = document.querySelector('#steps');
const first = document.createElement('li');
first.textContent = 'Open the file';
list.prepend(first);
const heading = document.querySelector('h1');
const intro = document.createElement('p');
intro.textContent = 'Follow these steps in order.';
heading.after(intro);
Strings passed to these methods are inserted as text, never parsed as HTML, so they are safe too: list.append('Plain text').
A node can only be in one place. Appending an element that is already on the page moves it. To place a copy, clone it first with el.cloneNode(true), where true copies its descendants too.
You will also see the older appendChild and insertBefore. They still work, but they take one node at a time and do not accept strings. The methods above are supported in all current browsers.
Rendering a list from data
Here is the core pattern: map each data item to an element, then insert them all at once. Start the course dashboard with this dashboard/index.html:
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Course dashboard</title>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>Course dashboard</h1>
<p id="status" role="status"></p>
<ul id="course-list"></ul>
</main>
</body>
</html>
Lesson 33 will load the courses from data/courses.json. For now, app.js holds a few of the same entries in an array:
const courses = [
{ id: 1, title: 'JavaScript Basics', topic: 'JavaScript', level: 'Beginner', lessons: 12, updated: '2026-09-14' },
{ id: 2, title: 'CSS Grid in Practice', topic: 'CSS', level: 'Intermediate', lessons: 8, updated: '2026-08-02' },
{ id: 3, title: 'Accessible Forms', topic: 'Accessibility', level: 'Intermediate', lessons: 6, updated: '2026-10-01' },
];
const list = document.querySelector('#course-list');
const status = document.querySelector('#status');
function createCourseItem(course) {
const item = document.createElement('li');
item.dataset.id = course.id;
const heading = document.createElement('h2');
heading.textContent = course.title;
const details = document.createElement('p');
details.textContent = `${course.topic}, ${course.level}, ${course.lessons} lessons`;
item.append(heading, details);
return item;
}
function renderCourses(items) {
list.replaceChildren(...items.map(createCourseItem));
status.textContent = `Showing ${items.length} courses.`;
}
renderCourses(courses);
Three things make this pattern work well:
createCourseItemis a small function that turns one data object into one element. It is easy to test and to change.replaceChildrenclears the old list and inserts the new items in a single step. CallrenderCoursesagain after filtering or sorting and the list is rebuilt correctly.item.dataset.idstores the course id on the element asdata-id="1", so event handlers can find the matching data later (lesson 29).
Calling list.replaceChildren() with no arguments empties the element. That is the safe way to clear content, rather than innerHTML = ''.
Building many nodes with a fragment
When you insert items one by one in a loop, collect them in a DocumentFragment first. A fragment is a lightweight container that is not part of the page. When you append it, its children move into place and the fragment is left empty:
const fragment = document.createDocumentFragment();
for (const course of courses) {
fragment.append(createCourseItem(course));
}
list.append(fragment);
replaceChildren(...items) and append(...items) give the same benefit in one call, so you need fragments mainly when building inside a loop.
Using a template element
Building markup line by line gets long when each item has many parts. The HTML template element lets you write the structure once in HTML. Its content is parsed but not shown, and you clone it for each item. The HTML course covers template itself; here is the JavaScript side.
Add this inside main, after the list:
<template id="course-template">
<li>
<h2 class="course-title"></h2>
<p class="course-meta"></p>
<p class="course-updated"></p>
</li>
</template>
Then clone and fill it:
const template = document.querySelector('#course-template');
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' });
function createCourseItem(course) {
const item = template.content.firstElementChild.cloneNode(true);
item.dataset.id = course.id;
item.querySelector('.course-title').textContent = course.title;
item.querySelector('.course-meta').textContent =
`${course.topic}, ${course.level}, ${course.lessons} lessons`;
item.querySelector('.course-updated').textContent =
`Updated ${dateFormat.format(new Date(course.updated))}`;
return item;
}
This version replaces the earlier createCourseItem. template.content is a document fragment holding the template's nodes. firstElementChild is the li, and cloneNode(true) gives a fresh, deep copy every time. You still fill the copy with textContent, so the template approach is just as safe. The date formatting comes from lesson 27.
Removing elements
el.remove() takes an element out of the page:
document.querySelector('.notice')?.remove();
To remove a course from the dashboard, find its item by its data-id:
function removeCourse(id) {
const item = list.querySelector(`li[data-id="${id}"]`);
item?.remove();
}
Keep focus somewhere sensible
If the removed element, or something inside it, had keyboard focus, focus is lost and usually falls back to the start of the page. A keyboard or screen reader user then has to find their place again. When you remove something the user just acted on, move focus deliberately first:
function removeCourseItem(item) {
const next = item.nextElementSibling ?? item.previousElementSibling;
const title = item.querySelector('h2').textContent;
item.remove();
const nextButton = next?.querySelector('button');
if (nextButton) {
nextButton.focus();
} else {
status.setAttribute('tabindex', '-1');
status.focus();
}
status.textContent = `${title} removed.`;
}
This assumes each item has a button, such as the Save button lesson 29 adds. Focus moves to the button in the neighbouring item, or to the status message if there is none, and the live region announces what happened. tabindex="-1" lets script focus the paragraph without adding it to the tab order. The Web Accessibility Essentials course covers focus management in depth.
Why not build HTML strings?
You will often see code like this:
// Avoid with any data that is not your own fixed text
list.innerHTML = courses.map((c) => `<li><h2>${c.title}</h2></li>`).join('');
It is short, but any < or " in the data is parsed as markup. A title from a server or a user can inject elements, break the layout, or run script through event handler attributes. It also destroys and recreates every element, losing focus and any listeners attached to them. Use createElement, template and textContent for data. Keep innerHTML for fixed markup you wrote yourself, if at all.
Summary
createElementmakes an element in memory; fill it withtextContentand attributes before inserting it.append,prepend,before,after,replaceWithandreplaceChildrenplace nodes precisely and treat strings as text.- Render lists by mapping each data item to an element, then insert them all with
replaceChildren. - Clone
template.contentfor larger structures, and fill the clone withtextContent. - Remove nodes with
remove(), and move focus deliberately when the removed element held it.