1Displaying JSON Data in a Web Page
▶
One of the most practical and frequently encountered tasks in modern web development is taking JSON data — whether fetched from a remote API, loaded from a local file, or generated dynamically — and presenting it meaningfully to the user inside a web page. This process bridges the gap between raw data and a polished user interface. It involves selecting the right place in the document to put your content, converting raw JSON text into a living JavaScript object, constructing HTML from that data, and injecting it into the page in a way that is both efficient and safe. Each of these steps has its own nuances, and mastering them together gives you the foundation for building virtually any data-driven web application.
The steps involved can be thought of as a pipeline: select a target element → parse the JSON → build HTML or DOM nodes → insert into the page → handle updates gracefully. The sections below walk through each stage in depth, with examples and explanations of the underlying browser APIs.
Selecting DOM Elements as Render Targets
Before you can display anything, you need a reference to the element in your HTML document that will serve as the container for your rendered data. The two most common methods for obtaining such a reference are document.getElementById() and document.querySelector().
document.getElementById('container') accepts an element's id attribute value as a string and returns the matching element, or null if none is found. It is fast and unambiguous because IDs are supposed to be unique per page. document.querySelector('.results') accepts any valid CSS selector — class names, attribute selectors, pseudo-classes, descendant combinators, and more — and returns the first matching element in document order. Its sibling document.querySelectorAll() returns a static NodeList of all matches.
// Using getElementById — best when you have a unique ID on your container
const container = document.getElementById('product-list');
// Using querySelector — flexible, accepts any CSS selector
const resultsPanel = document.querySelector('.results-panel');
// querySelectorAll — returns all matching elements
const cards = document.querySelectorAll('.card');
A key best practice is to store the reference in a variable rather than calling the selector method repeatedly. Every call to querySelector causes the browser to traverse the DOM tree again. Caching the result in a variable means the lookup happens once and you reuse the same reference throughout your rendering code, which is both faster and cleaner.
Choosing the right semantic HTML container for your content matters beyond aesthetics. Semantics help screen readers, search engines, and other assistive technologies understand the structure of your page. Common choices include:
| Data Shape | Recommended Container | Reason |
|---|---|---|
| Unordered collection of items | <ul> with <li> children |
Communicates list semantics to screen readers |
| Ordered / ranked items | <ol> with <li> children |
Implies sequence or priority |
| Tabular / relational data | <table> with <thead> / <tbody> |
Allows column headers and row relationships |
| Card-based layouts | <div> or <article> wrappers |
<article> suits self-contained content units |
For example, if you are rendering a list of blog posts, wrapping them in a <ul> element rather than a generic <div> is immediately more meaningful to both humans and machines.
Parsing Fetched JSON into a JavaScript Object
JSON data arrives in one of two forms: as a raw string (when you use XMLHttpRequest, read a file, or receive a message over a WebSocket), or implicitly parsed when you use the modern Fetch API with its built-in response.json() method.
response.json() is a method on the Response object returned by fetch(). It reads the response body to completion, then parses it as JSON and returns a Promise that resolves to the resulting JavaScript value. This makes it very natural to use inside an async/await function or a .then() chain:
// async/await style — reads top to bottom like synchronous code
async function loadProducts() {
const container = document.getElementById('product-list');
try {
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error(`HTTP error — status: ${response.status}`);
}
const products = await response.json(); // resolves to a JS array or object
renderProducts(products, container);
} catch (error) {
container.textContent = `Failed to load products: ${error.message}`;
}
}
// Equivalent .then() chain style
fetch('https://api.example.com/products')
.then(response => {
if (!response.ok) throw new Error(`HTTP error — status: ${response.status}`);
return response.json();
})
.then(products => renderProducts(products, container))
.catch(error => { container.textContent = error.message; });
When you already have a JSON string — perhaps stored in a variable, retrieved from localStorage, or passed as a data attribute — you parse it manually with JSON.parse():
const jsonString = '{"name":"Alice","age":30}';
try {
const user = JSON.parse(jsonString);
console.log(user.name); // "Alice"
} catch (error) {
// JSON.parse throws a SyntaxError for malformed JSON
console.error('Invalid JSON:', error.message);
}
Wrapping JSON.parse() in a try/catch block is essential because any syntax error in the string — a missing closing brace, an unquoted key, a trailing comma — will throw a SyntaxError and halt execution if unhandled. The try/catch lets you recover gracefully, log the error, or show the user a helpful message.
Once parsed, the resulting value is a plain JavaScript object or array, and you access its properties with familiar notation:
const product = { id: 1, name: 'Widget', price: 9.99, tags: ['sale', 'new'] };
// Dot notation — clean and readable when key is a valid identifier
console.log(product.name); // "Widget"
// Bracket notation — necessary when the key is dynamic or not a valid identifier
const key = 'price';
console.log(product[key]); // 9.99
// Nested access
const data = { user: { address: { city: 'Springfield' } } };
console.log(data.user.address.city); // "Springfield"
Building HTML Strings from JSON Data
One of the quickest ways to render JSON data is to build a string of HTML markup from the data and assign it directly to an element's innerHTML property. This approach is concise and works well for trusted, controlled data.
The typical pattern uses Array.map() to transform each object in a parsed array into an HTML fragment string, then .join('') to concatenate all fragments into a single string, which is finally assigned to element.innerHTML:
function renderProducts(products, container) {
const html = products.map(product => `
<li class="product-card">
<h2>${product.name}</h2>
<p>Price: $${product.price.toFixed(2)}</p>
<p>Category: ${product.category}</p>
</li>
`).join('');
container.innerHTML = `<ul class="product-list">${html}</ul>`;
}
Using join('') (joining with an empty string) avoids the commas that would appear if you called .toString() on the array directly. A for loop with string concatenation works equally well when you prefer imperative style:
function renderProducts(products, container) {
let html = '<ul>';
for (const product of products) {
html += `<li>${product.name} — $${product.price}</li>`;
}
html += '</ul>';
container.innerHTML = html;
}
Setting innerHTML is powerful but carries an important security caveat. If any part of the data originates from user input or an untrusted source, an attacker could inject malicious HTML or JavaScript — a vulnerability known as Cross-Site Scripting (XSS). For example, if a product name were the string <img src=x onerror=alert('hacked')>, assigning it directly via template literals into innerHTML would execute that script.
To guard against this, sanitize untrusted values before embedding them in HTML. The simplest approach is to escape special HTML characters:
function escapeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const safeName = escapeHTML(product.name);
html += `<li>${safeName}</li>`;
For more complex scenarios, established libraries such as DOMPurify provide robust sanitization. Alternatively, bypassing innerHTML entirely by creating DOM nodes programmatically (described next) is inherently safer because text content is never interpreted as markup.
Creating and Appending DOM Nodes Programmatically
Rather than building an HTML string, you can construct individual DOM elements using the document.createElement() API. This approach is inherently XSS-safe when you set content via textContent, because the browser treats the value as plain text rather than markup.
function createProductItem(product) {
// Create the list item element
const li = document.createElement('li');
li.className = 'product-card'; // set a class directly
// Create a heading for the product name
const heading = document.createElement('h2');
heading.textContent = product.name; // safe — never parsed as HTML
// Create a paragraph for the price
const price = document.createElement('p');
price.textContent = `Price: $${product.price.toFixed(2)}`;
// Set an attribute (e.g., a data attribute for later use)
li.setAttribute('data-id', product.id);
// Assemble the hierarchy
li.appendChild(heading);
li.appendChild(price);
return li;
}
Key methods and properties involved:
document.createElement(tagName)— creates a new element node of the specified type. It is not attached to the document yet; it exists in memory.element.textContent— sets or gets the text content of a node and all its descendants. UnlikeinnerHTML, it does not parse HTML, so special characters are never interpreted as tags.element.setAttribute(name, value)— sets an HTML attribute. Useful forhref,src,data-*attributes,aria-*attributes, and so on.element.className— directly sets the element'sclassattribute as a string; convenient for single classes. For adding or removing individual classes on existing elements,element.classList.add()andelement.classList.remove()are more flexible.parentElement.appendChild(newElement)— appends a node as the last child of the parent. The older, standards-compliant method.parentElement.append(...nodes)— newer, accepts multiple nodes or strings and appends them all. Strings are treated as text nodes.
Iterating Over JSON Arrays to Generate Repeated Elements
Most real-world JSON responses are arrays of objects — a list of products, users, articles, or records. Iterating over these arrays and creating a DOM element for each object is the core rendering loop in any data-driven UI.
You can use forEach, for...of, or map(). All are appropriate; the choice depends on whether you want a return value (map returns a new array) or purely side effects (forEach and for...of).
// forEach — good when you just want to iterate and build nodes
products.forEach(product => {
const item = createProductItem(product);
container.appendChild(item);
});
// for...of — clean, readable, supports break/continue
for (const product of products) {
const item = createProductItem(product);
container.appendChild(item);
}
// map — returns an array of elements you can append in bulk
const items = products.map(product => createProductItem(product));
A significant performance consideration when appending many nodes is the number of times you trigger a browser reflow (recalculation of layout). Each time you append a node to an element that is already in the document, the browser may recalculate styles and layout. If you append 500 items one by one, that could mean 500 reflow operations.
The solution is to use a DocumentFragment. A DocumentFragment is a lightweight, in-memory container that is not part of the live document. You append all your new nodes to the fragment, and then append the fragment itself to the real DOM in a single operation. The browser only reflows once:
function renderProducts(products, container) {
// Create an off-screen fragment
const fragment = document.createDocumentFragment();
products.forEach(product => {
// Pull out specific properties
const { id, name, price, category } = product;
const li = document.createElement('li');
li.setAttribute('data-id', id);
const nameEl = document.createElement('strong');
nameEl.textContent = name;
const detailEl = document.createElement('span');
detailEl.textContent = ` — ${category} — $${price.toFixed(2)}`;
li.appendChild(nameEl);
li.appendChild(detailEl);
// Append to fragment, NOT to the real DOM yet
fragment.appendChild(li);
});
// Single DOM insertion — one reflow
container.appendChild(fragment);
}
Inside the loop, always extract only the properties you need. Using destructuring (const { name, price } = product) makes the code cleaner and your intent explicit. When working with nested JSON objects, drill into them with chained dot notation: product.seller.name, product.images[0].url.
Updating the DOM Dynamically After Data Changes
In interactive applications, you often need to re-render content — for instance, when the user filters a list, requests the next page of results, or when a server pushes fresh data. Re-rendering requires first clearing the old content so you do not accumulate duplicate elements.
The simplest clearing strategy is setting innerHTML to an empty string:
container.innerHTML = ''; // removes all child nodes at once
An alternative that avoids touching innerHTML is a while loop that removes children one at a time. This is slightly more verbose but avoids any side effects of resetting innerHTML:
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Or more concisely with the newer replaceChildren():
container.replaceChildren(); // removes all children, no arguments needed
Once cleared, you re-run your rendering function with the new data. A full update cycle might look like this:
async function refreshData(apiUrl, container, loadingEl, errorEl) {
// 1. Show a loading indicator
loadingEl.hidden = false;
errorEl.hidden = true;
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`Server error: ${response.status}`);
const data = await response.json();
// 2. Clear old content
container.replaceChildren();
// 3. Render new content
renderProducts(data.products, container);
} catch (err) {
// 4. Show error message in the DOM
errorEl.textContent = `Could not load data: ${err.message}`;
errorEl.hidden = false;
} finally {
// 5. Always hide the loading indicator
loadingEl.hidden = true;
}
}
Providing user feedback throughout the async lifecycle is crucial for a good experience. A loading indicator (a spinner, a skeleton screen, or even a simple "Loading…" text node) tells the user the application is working and not frozen. An error message rendered into the DOM — rather than only logged to the console — ensures that when something goes wrong, the user understands what happened and can take action, rather than staring at a blank panel.
The complete picture of the data-to-DOM pipeline, combining all the steps above, can be summarized as follows:
| Step | Primary API / Technique | Key Consideration |
|---|---|---|
| Select render target | getElementById, querySelector |
Cache the reference; choose semantic container element |
| Fetch data | fetch() with async/await |
Check response.ok; handle network errors in catch |
| Parse JSON | response.json() or JSON.parse() |
Wrap JSON.parse in try/catch for malformed input |
| Build HTML string | Array.map() + template literals + innerHTML |
Sanitize untrusted data before inserting as HTML |
| Create DOM nodes | createElement, textContent, setAttribute |
Using textContent is XSS-safe by default |
| Iterate array | forEach, for...of, map() |
Use DocumentFragment to batch insertions and minimize reflows |
| Clear and update | innerHTML = '' or replaceChildren() |
Show loading/error feedback; re-render on each data refresh |
Mastering these techniques gives you a complete, flexible toolkit for transforming any JSON data structure into a rich, interactive, and accessible user interface — whether you are building a simple product listing, a real-time dashboard, or a complex data explorer.