Strategies to avoid using innerHTML
The use of innerHTML is a fast and convenient way to create DOM nodes. The
issue is that it encourages a pattern where entire DOM trees are replaced instead
of being updated selectively. Even if it is only used for initial rendering, it
can easily lead to adopting the same pattern for updates later, which may cause
layout flicker, loss of state, and unnecessary re-rendering. Replacing an existing
DOM tree is highly inefficient; it is better to use explicit DOM manipulation
methods or data-driven rendering approaches instead. Some common alternatives to
innerHTML include:
textContentto safely replace textcreateElement(),append(), or templating functions to build new structuresCSS or visibility toggles instead of rebuilding markup
libraries such as lighterhtml, which create DOM trees efficiently and update them via diffing instead of replacement
If external or user-provided HTML must be rendered, it has to be sanitized first
(for example, with DOMPurify).
insertAdjacentHTML() works well with sanitized markup and will also benefit
from the upcoming built-in Sanitizer API,
allowing code to drop the extra sanitization step later without further restructuring.
Note
Using innerHTML is accepted for add-ons hosted on ATN when it is not used
to update existing DOM nodes. However, the alternatives described in this guide
are generally suggested.
More information on this topic is available on MDN.
Update content via span placeholders
Consider the following code:
const message = document.getElementById('message');
message.innerHTML = `The following <b>${counts}</b> items have been found:`;
Here, innerHTML is used just to insert a formatted value. A better
approach is to include the static part directly in the markup and only
update the dynamic part.
<div id="message">
The following <b><span data-msg="counts"></span></b> items have been found:
</div>
document.querySelector('#message span[data-msg="counts"]').textContent = counts;
This avoids HTML parsing entirely and ensures the inserted value is treated as plain text.
Update content by hiding/showing markup via CSS
Consider the following markup and code:
<div id="status"></div>
const statusElement = document.getElementById("status");
if (error) {
statusElement.innerHTML = `<div class="red">Something went wrong: ${error}</div>`;
} else {
statusElement.innerHTML = `<div class="green">Success!</div>`;
setTimeout(() => statusElement.innerHTML = "", 3000);
}
A more efficient approach involves defining both states in advance and toggling their visibility with CSS:
<div data-view="none" id="status">
<div class="red">Something went wrong: <span data-msg="error"></span></div>
<div class="green">Success!</div>
</div>
#status div.green, #status div.red { display: none; }
#status[data-view="green"] div.green { display: revert; }
#status[data-view="red"] div.red { display: revert; }
const statusElement = document.getElementById("status");
if (error) {
statusElement.querySelector('span[data-msg="error"]').textContent = error;
statusElement.dataset.view = "red";
} else {
statusElement.dataset.view = "green";
setTimeout(() => statusElement.dataset.view = "none", 3000);
}
This method keeps the DOM stable, avoids expensive reflows, and separates logic from presentation.
Insert dynamic content using templates
Consider the following code:
if (error) {
const message = document.createElement('p');
message.innerHTML = `Missing configuration. <a href="#" onclick="browser.runtime.openOptionsPage(); window.close();">Open settings to update configuration</a>`;
document.getElementById('configs').appendChild(message);
}
Instead of dynamically generating HTML, define a <template> in the
markup and populate it programmatically:
<template id="missing-config-template">
<p>
Missing configuration.
<a href="#" data-action="open-settings">Open settings to update configuration</a>
</p>
</template>
const template = document.getElementById('missing-config-template');
const message = template.content.cloneNode(true);
const link = message.querySelector('[data-action="open-settings"]');
link.addEventListener('click', event => {
event.preventDefault();
browser.runtime.openOptionsPage();
window.close();
});
document.getElementById('configs').appendChild(message);
This approach avoids both uses of innerHTML and inline event handlers,
ensures safe text insertion, and cleanly separates structure from behavior.
Inserting and updating content dynamically with lighterhtml
The lighterhtml library (based
on hyperHTML) uses template literals
and allows creating DOM trees from strings just like innerHTML, but later
updates to already rendered nodes are done incrementally instead of being fully
torn down and rebuilt from scratch.
Bundle lighterhtml with the add-on
Download the desired
lighterhtmlrelease from a CDN such as jsDelivr or cdnjs (for example, version 4.2.0) from a trusted source such as https://cdn.jsdelivr.net/npm/lighterhtml@4.2.0/min.min.jsInclude it in the extension under a local folder, for example in
vendor/lighterhtml.min.jsDocument this dependency in a file named
VENDOR.mdin the root of the extension. The file should specify the file name and the original source URL:VENDOR.mdlighterhtml.js: https://cdn.jsdelivr.net/npm/[email protected]/min.min.js
This allows reviewers to verify that the file is unchanged.
Create DOM nodes from strings
Load the lighterhtml library:
<html>
<head>
<script src="/vendor/lighterhtml.min.js"></script>
<script defer src="popup.js"></script>
</head>
<body>
...
</body>
</html>
Use lighterhtml.html.node to create DOM nodes via template literals:
// Shortcut.
const lhNode = lighterhtml.html.node;
const list = ['some', '<b>nasty</b>', 'list'];
const node = lhNode`
<p>This is a simple <i>test</i></p>
<ul>${list.map(text => lhNode`
<li>${text}</li>
`)}
</ul>
`
document.body.appendChild(node);
Render and update DOM nodes from strings
Use lighterhtml.html and lighterhtml.render to create wired content,
which can be updated later:
const names = [
'Arianna',
'Luca',
'Isa'
]
setInterval(greetings, 2000);
function greetings() {
names.unshift(names.pop());
lighterhtml.render(
document.body, lighterhtml.html`${names.map(
name => lighterhtml.html`<p>Hello ${name}!</p>`
)}`
);
}
The library supports many additional features, such as automatically converting
onclick attributes into real event listeners.
Safely sanitizing external markup with DOMPurify
In some cases, an extension may need to display externally sourced or user-generated
HTML, for example, when rendering message previews or feed entries. In such
situations, using innerHTML or any other method to directly insert the raw
HTML is unsafe, because it allows potentially malicious HTML or script content
to be injected into the page.
To handle this scenario safely, the recommended approach is to sanitize the
markup first using DOMPurify, and then
insert the sanitized content using insertAdjacentHTML().
Bundle DOMPurify with the add-on
Download the desired
DOMPurifyrelease from a CDN such as jsDelivr or cdnjs (for example, version 3.2.7) from a trusted source such as https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.jsInclude it in the extension under a local folder, for example in
vendor/purify.min.jsDocument this dependency in a file named
VENDOR.mdin the root of the extension. The file should specify the file name and the original source URL:VENDOR.mdpurify.min.js: https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js
This allows reviewers to verify that the file is unchanged.
Insert purified markup with insertAdjacentHTML()
Load the DOMPurify library:
<html>
<head>
<script src="/vendor/purify.min.js"></script>
<script defer src="popup.js"></script>
</head>
<body>
<div id="preview"></div>
</body>
</html>
Sanitize external HTML and add it to the DOM via insertAdjacentHTML():
async function renderExternalMarkup(url) {
const response = await fetch(url);
const rawHtml = await response.text();
// Sanitize the received HTML.
const safeHtml = DOMPurify.sanitize(rawHtml);
// Insert the sanitized markup.
const preview = document.getElementById('preview');
preview.insertAdjacentHTML('beforeend', safeHtml);
}
renderExternalMarkup('https://example.com/feed-entry.html');
This combination provides a controlled way to render external HTML safely within
Thunderbird extensions. In the future, insertAdjacentHTML() will support
built-in sanitization with the
Sanitizer API,
but for now, DOMPurify remains necessary.