CodeToolProCodeToolPro
GitHub
Formatters·7 min read

HTML Formatter Guide: Pretty-Print & Beautify HTML

CodeToolPro Team·

HTML Formatter Guide: Pretty-Print & Beautify HTML

Minified HTML — the kind bundlers and templates spit out — is dense and nearly impossible to scan by eye. A single line can hold an entire component. An HTML formatter turns that wall of markup into a readable, indented tree so you can actually see the structure, spot a missing closing tag, or review a diff without squinting.

This guide explains how an HTML formatter works, the nesting and indentation rules it follows, how it treats void elements and comments, the mistakes people make when relying on minified markup, and when a browser tool beats writing your own script. Every example was produced with our HTML Formatter — your input never leaves the page.

What Is an HTML Formatter?

An HTML formatter is a tool that parses a markup string and rewrites it with consistent line breaks and indentation, one level per nesting depth. Unlike a full HTML parser, it does not build a DOM or fix semantics — it rearranges whitespace so the hierarchy is visually obvious.

The core operations are:

  • Pretty-print — put each tag on its own line and indent nested elements.
  • Normalize whitespace — collapse runs of spaces between tags and trim stray newlines.
  • Preserve content — keep attributes, text, comments, and the doctype exactly as written.
  • Separate blocks — leave blank lines between sibling top-level elements for readability.

Why Format HTML?

Minified HTML is great for the wire and terrible for humans. Formatting matters when:

  • Debugging a layout bug — a misplaced </div> is invisible in one line but obvious once indented.
  • Reviewing a pull request — indented markup produces clean diffs instead of one changed mega-line.
  • Learning a codebase — reading server-rendered templates is far easier when nested correctly.
  • Hand-editing a snippet — pasting formatted HTML into a doc or an email stays readable.

Rule of thumb: ship minified, develop formatted.

How the Formatter Works

The tool walks the source character by character. When it meets <, it classifies the token:

  1. Doctype (<!DOCTYPE html>) — printed on its own line, unchanged.
  2. Comment (<!-- ... -->) — printed verbatim at the current indent.
  3. Closing tag (</div>) — outdent by one level, then print.
  4. Opening tag (<div>) — print at the current indent, then indent one level deeper for children.
  5. Void element (<img>, <br>, <input>, …) — printed like an opening tag but does not increase indent, because it cannot contain children.

Text nodes are trimmed of surrounding whitespace and placed on their own indented line. The result mirrors the document tree.

By default the tool indents two spaces per level — the same convention most HTML tooling uses. Top-level blocks (two sibling <div>s, for example) are separated by a blank line so the document's main sections are easy to tell apart at a glance.

Input shapeWhat happens
<div><p>Hi</p></div>Each tag gets its own line, p indented under div
<img src="a.png">Stays inline, does not push following tags deeper
<!-- note -->Kept exactly, indented to match its context
<!--note--> (no space)Still recognized and preserved as a comment

Common Mistakes

  • Assuming formatting validates HTML — a formatter only moves whitespace; it will not tell you a tag is unclosed semantically. Use a validator for that.
  • Re-formatting already-rendered output — pasting browser "view source" that contains insignificant whitespace can change layout if your CSS depends on whitespace between inline elements.
  • Expecting attribute reordering — legitimate formatters keep attributes in their original order; don't rely on a canonical order.
  • Losing template syntax — framework templates ({% %}, {{ }}, JSX expressions) are not HTML; run them through a language-aware pretty-printer, not a plain HTML one.

Hands-on: Tested with the Tool

I ran our HTML Formatter on three real inputs to confirm the behavior described above.

Input 1 — the default one-line document:

<!DOCTYPE html><html><head><title>Hello</title></head><body><div class="container"><h1>Hello World</h1><p>This is a paragraph.</p></div></body></html>

Output: each tag on its own line, 2-space indentation per level:

<!DOCTYPE html>
<html>
  <head>
    <title>
      Hello
    </title>
  </head>
  <body>
    <div class="container">
      <h1>
        Hello World
      </h1>
      <p>
        This is a paragraph.
      </p>
    </div>
  </body>
</html>

Input 2 — void elements mixed with nested lists:

<ul><li>Item one</li><li>Item two</li></ul><img src="a.png" alt="x"><input type="text" value="hi"/><br>

Output: <img>, <input>, and <br> do not increase indentation, so </ul> returns to the top level:

<ul>
  <li>
    Item one
  </li>
  <li>
    Item two
  </li>
</ul>
<img src="a.png" alt="x">
<input type="text" value="hi"/>
<br>

Input 3 — an HTML comment:

<!-- nav --><nav><a href="/">Home</a></nav>

Output: the comment is preserved verbatim and indented to match its context:

<!-- nav -->
<nav>
  <a href="/">
    Home
  </a>
</nav>

The reproducible steps: open the tool, paste any of the inputs above, and the formatted result appears instantly. Notice the tool keeps attribute quotes and values intact and never rewrites tag names — only whitespace and line breaks change.

Code Examples

JavaScript

The in-browser tool uses a small character scanner. The same logic, as a reusable function:

const VOID = new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]);

function formatHTML(html) {
  let result = "", indent = 0;
  const INDENT = "  ";
  let i = 0;
  while (i < html.length) {
    if (html[i] === "<") {
      if (result && !result.endsWith("\n") && !result.endsWith(">")) result += "\n";
      const closing = html[i + 1] === "/";
      const comment = html.startsWith("<!--", i);
      const doctype = html.startsWith("<!DOCTYPE", i) || html.startsWith("<!doctype", i);
      if (comment) {
        if (result && !result.endsWith("\n")) result += "\n";
        const end = html.indexOf("-->", i) + 3;
        result += INDENT.repeat(indent) + html.slice(i, end) + "\n";
        i = end; continue;
      }
      if (doctype) {
        const end = html.indexOf(">", i) + 1;
        result += html.slice(i, end) + "\n"; i = end; continue;
      }
      if (closing) {
        indent = Math.max(0, indent - 1);
        if (result && !result.endsWith("\n")) result += "\n";
        const end = html.indexOf(">", i) + 1;
        result += INDENT.repeat(indent) + html.slice(i, end) + "\n";
        i = end; continue;
      }
      if (result && !result.endsWith("\n") && result.length) result += "\n";
      result += INDENT.repeat(indent);
      const end = html.indexOf(">", i);
      const tag = html.slice(i + 1, end).split(/\s/)[0].toLowerCase();
      result += html.slice(i, end + 1);
      if (!VOID.has(tag)) indent++;
      result += "\n"; i = end + 1;
    } else if (html[i].trim() === "") {
      if (i > 0 && html[i - 1] !== ">" && !result.endsWith("\n")) result += " ";
      i++;
    } else {
      if (result.endsWith("\n")) result += INDENT.repeat(indent);
      const next = html.indexOf("<", i);
      const text = next === -1 ? html.slice(i) : html.slice(i, next);
      const trimmed = text.trim();
      if (trimmed) result += trimmed;
      i = next === -1 ? html.length : next;
      if (!(i < html.length && html[i] === "<" && html[i + 1] === "/") && i < html.length) result += "\n";
    }
  }
  return result.trim();
}

console.log(formatHTML(`<!DOCTYPE html><html><head><title>Hello</title></head><body><div class="container"><h1>Hello World</h1><p>This is a paragraph.</p></div></body></html>`));

Running this prints the same indented tree shown in the Hands-on section.

Python

The equivalent in Python, using a small token scanner:

import re

VOID = {"area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"}

def format_html(html: str) -> str:
    tokens = re.findall(r"<!--.*?-->|<!DOCTYPE[^>]*>|</?[^>]+>|[^<]+", html, re.S)
    out, indent = [], 0
    for raw in tokens:
        s = raw.strip()
        if not s:
            continue
        if s.lower().startswith("<!doctype") or s.startswith("<!--"):
            out.append("  " * indent + s)
        elif s.startswith("</"):
            indent = max(0, indent - 1)
            out.append("  " * indent + s)
        elif s.endswith("/>") or (s.startswith("<") and s.split()[0][1:].lower() in VOID):
            out.append("  " * indent + s)
        elif s.startswith("<"):
            out.append("  " * indent + s)
            indent += 1
        else:
            out.append("  " * indent + s)
    return "\n".join(out)

print(format_html('<!DOCTYPE html><html><head><title>Hello</title></head><body><div class="container"><h1>Hello World</h1><p>This is a paragraph.</p></div></body></html>'))

Both snippets produce identical output for the same input, which is exactly what the browser tool returns.

Related Tools

When to Use a Formatter Instead of Code

You can pipe HTML through a CLI or write a script, and for automated build pipelines you should. But a browser formatter wins for ad-hoc work: you copied a blob of markup from a response, a CMS, or a teammate's chat and want to read it now — no install, no packages, no secrets sent to a server. For one-off inspection and quick edits, use the tool; for repeatable, version-controlled formatting, keep the script.