Safe DOM Rendering Patterns in JavaScript

Learn safe DOM rendering patterns in JavaScript. Master textContent, createElement, safe attribute handling, HTML sanitization, and CSP to prevent injection attacks.

8 min read

Every time JavaScript inserts dynamic content into the DOM, there is a choice: a safe API that treats the content as data, or a dangerous API that treats it as code. The difference is whether user input gets rendered as plain text or gets parsed as HTML.

The safe path is always to use APIs that never interpret strings as HTML. Picking the right API for the job means understanding which DOM methods parse strings and which treat them as inert text. This article covers the safe rendering patterns for every DOM insertion scenario, building on fundamental DOM selection and manipulation skills with security in mind.

The Golden Rule: Content vs Code

Safe vs unsafe DOM insertion paths

The decision tree is simple. If the string should be text, use textContent. If you need HTML structure, use createElement and build nodes. If you must render user-provided HTML, sanitize it first. Never let an unsanitized string cross the boundary from data to code.

Pattern 1: Rendering Text Content

The most common rendering task is inserting text. The safe way is textContent:

javascriptjavascript
// Unsafe
document.getElementById("username").innerHTML = userProfile.name;
 
// Safe
document.getElementById("username").textContent = userProfile.name;

textContent treats the value as plain text regardless of what it contains. HTML tags, script blocks, and event handlers are displayed as literal characters. There is no way to inject HTML through textContent.

A worked example with a truly malicious input:

javascriptjavascript
const maliciousName = `<img src=x onerror="alert('stolen: ' + document.cookie)">`;
 
// Using innerHTML: the script executes
el.innerHTML = maliciousName; // VULNERABLE
 
// Using textContent: the script is displayed as text
el.textContent = maliciousName; // Safe -- user sees the angle brackets

innerText is also safe and behaves similarly, but it triggers a layout recalculation. Prefer textContent for performance.

Pattern 2: Building HTML Structure Safely

When you need actual HTML elements, build them programmatically with createElement and setAttribute:

javascriptjavascript
// Unsafe: building HTML with string concatenation
function renderCommentBad(comment) {
  return `
    <div class="comment">
      <strong>${comment.author}</strong>
      <p>${comment.body}</p>
    </div>
  `;
}
container.innerHTML += renderCommentBad(comment);
 
// Safe: building DOM nodes directly
function renderCommentSafe(comment) {
  const div = document.createElement("div");
  div.className = "comment";
 
  const strong = document.createElement("strong");
  strong.textContent = comment.author;
  div.appendChild(strong);
 
  const p = document.createElement("p");
  p.textContent = comment.body;
  div.appendChild(p);
 
  return div;
}
container.appendChild(renderCommentSafe(comment));

The safe version never concatenates user data into an HTML string. Every piece of user content is assigned through textContent on an element created by createElement. The browser handles all escaping internally.

Pattern 3: Setting Attributes Safely

Attributes have their own injection risks. The setAttribute method is safe for most attributes, but be careful with event handlers and URL-bearing attributes:

javascriptjavascript
// Safe: plain attributes
element.setAttribute("data-id", userInput);        // Safe
element.setAttribute("class", userInput);           // Safe
element.setAttribute("title", userInput);           // Safe (text attribute)
 
// Unsafe: event handler attributes
element.setAttribute("onclick", userInput);         // VULNERABLE
 
// Unsafe: URL attributes with untrusted values
element.setAttribute("href", userInput);            // VULNERABLE (javascript: URL)
element.setAttribute("src", userInput);             // VULNERABLE (javascript: URL)

For event handlers, always use addEventListener:

javascriptjavascript
// Safe
element.addEventListener("click", myHandler);
 
// Never do this
element.setAttribute("onclick", "myHandler()");

For URL attributes (href, src, action, formaction), validate that the value is a safe URL:

javascriptjavascript
function safeSetHref(element, url) {
  // Only allow http:, https:, and relative URLs
  if (url.startsWith("http:") || url.startsWith("https:") || url.startsWith("/")) {
    element.href = url;
  } else {
    element.href = "#";
    console.warn("Blocked unsafe URL:", url);
  }
}

The javascript: URL scheme is executable. Never let untrusted input set an href or src without validation.

Pattern 4: Rendering Rich HTML with Sanitization

When you must render user-provided HTML, a text editor's content or an email body, use DOMPurify:

javascriptjavascript
import DOMPurify from "dompurify";
 
function renderRichContent(htmlString) {
  const clean = DOMPurify.sanitize(htmlString, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li"],
    ALLOWED_ATTR: ["href", "target"]
  });
 
  document.getElementById("content").innerHTML = clean;
}

DOMPurify parses the HTML, walks every node, and removes anything dangerous: script tags, event handler attributes (onerror, onload, onclick), javascript: URLs, and HTML comments that could hide malicious code.

Configure the allowed tags and attributes to the minimum your application needs. If your users only need bold and italic, do not allow a tags or img tags.

Pattern 5: Rendering into Different Contexts

Different parts of the DOM need different escaping. A string safe for an HTML body might be dangerous in an attribute or inside a script tag:

javascriptjavascript
// HTML body context: escape < > & " '
function escapeHtml(str) {
  return str.replace(/[&<>"']/g, char => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;",
    '"': "&quot;", "'": "&#x27;"
  })[char]);
}
 
// URL parameter context: encodeURIComponent
function buildUrl(base, params) {
  const qs = Object.entries(params)
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join("&");
  return `${base}?${qs}`;
}
 
// CSS context: very limited safe usage
// Prefer setting individual style properties instead
element.style.color = userColor; // Validate the color value first

The table of escaping by context:

ContextEscaping methodExample
HTML bodytextContent or HTML entity encoding<div>USER</div>
HTML attributesetAttribute or quote and encodetitle="USER"
URL query stringencodeURIComponent?q=USER
URL pathencodeURI (preserves slashes)/search/USER
CSS valuesValidate against allowlistcolor: USER
JavaScript stringJSON.stringifyconst x = "USER"

Never mix contexts. HTML escaping does not protect a value inside a URL. URL encoding does not protect a value inside HTML. Each context has its own rules.

Pattern 6: Content Security Policy as a Safety Net

Even with careful escaping, mistakes happen. A Content Security Policy is the final defense:

httphttp
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  object-src 'none';
  base-uri 'self';
  form-action 'self';

This policy means: even if an attacker injects <script>alert(1)</script> into the page through a missed escape, the browser does not execute it. CSP blocks inline scripts unless they have a matching nonce or hash.

For deeper coverage of CSP and injection prevention, see the XSS prevention guide.

Common Mistakes

Using innerHTML for simple text. The most common and most dangerous pattern. Every innerHTML = userString is a potential XSS. Use textContent.

Calling a sanitizer but then modifying the result. If you sanitize HTML, then do string operations on it, you can reintroduce vulnerabilities. Sanitize last, right before insertion.

Trusting sanitized output in attributes. DOMPurify cleans HTML elements and their attributes. But if you extract an href from sanitized HTML and set it with setAttribute("href", value), validate the URL separately.

Forgetting that JSON data can contain HTML. An API response like {"name": "<script>..."} is valid JSON. When you render that name into the DOM, escape it.

Rune AI

Rune AI

Key Insights

  • Use textContent for text, createElement for HTML structure, never innerHTML with untrusted strings.
  • Escape for the right context: HTML entities, URL encoding, and CSS escaping are all different.
  • Sanitize HTML with DOMPurify when you must render user-provided rich text.
  • A Content Security Policy is your safety net if rendering defenses fail.
  • Every rendering path (HTML, attributes, URLs, CSS, JavaScript) has its own escaping rules.
RunePowered by Rune AI

Frequently Asked Questions

Is textContent always safe?

Yes. textContent treats its value as plain text. The browser never parses it as HTML, so script tags, event handlers, and HTML entities are all displayed as literal text. It is the safest way to insert untrusted content.

Can I safely use innerHTML with a sanitizer?

Yes, with a well-maintained library like DOMPurify. The sanitizer parses the HTML, removes dangerous elements and attributes, and returns clean HTML. Never use innerHTML with unsanitized content, even if you think it is safe.

Is createElement safer than innerHTML?

Yes. createElement builds DOM nodes directly through the browser's API. There is no HTML parsing step, so there is no opportunity for injection. Set content with textContent and attributes with setAttribute for maximum safety.

Conclusion

Safe DOM rendering is about using the right API for the right context. textContent for text, createElement for structure, setAttribute for attributes, and DOMPurify when you must render HTML. Combine these with a Content Security Policy, and user-generated content becomes safe to display.Safe DOM rendering is about choosing the right API for every insertion. textContent for text. createElement for structure. setAttribute with validation for attributes. DOMPurify for rich HTML. CSP as the safety net. The mental model is simple: every string that crosses from JavaScript into the DOM must go through one of these safe paths. There is no such thing as "probably safe" user input. If it came from outside your code, treat it as hostile and render it safely. The safest code is the code that never calls innerHTML, outerHTML, or document.write. Build the habit of reaching for textContent and createElement first. They are not just safe. They make your intent clearer.