node-html-parser
node-html-parser turns an HTML string into a simplified DOM tree you can walk and query with CSS selectors. It is not a browser DOM and not a specification-conformant parser: the stated design goal is parsing large HTML files as cheaply as possible, so it handles the common malformed cases (an unclosed td, for example) and accepts that some broken markup will come out differently than a browser would render it. The node API borrows familiar names, so querySelector, querySelectorAll, closest, classList, innerHTML and textContent are all there, plus a few of its own such as structuredText and range. Two dependencies, TypeScript types included, and both ESM and CommonJS entry points.
A good pick for server-side HTML wrangling where you want selectors and low overhead and can live with a parser that trades correctness for speed. Reach for parse5 when the markup is hostile and for cheerio when you want the ecosystem.
Use it if
- You scrape or post-process HTML on the server and mainly need to select nodes, read text and pull attributes
- You are handling large documents or many of them, and parsing cost shows up in your profile
- You want CSS selector queries without pulling in a full DOM implementation or a headless browser
- You need the byte offsets of a node in the original source, which the range property gives you
- You need the parse tree a browser would build: this parser is explicit that malformed HTML may not come out correctly, so use parse5 when fidelity matters
- You want the jQuery-style API and the surrounding ecosystem, which is what cheerio exists for
- The page builds its content with JavaScript, since nothing here executes scripts and you will get the empty shell
- Raw speed is the deciding factor: the project's own benchmark in the README puts htmlparser2 and htmljs-parser ahead of it
- You are shipping to the browser, where DOMParser is built in and free rather than another 45 KB gzipped
Setup reality
npm install node-html-parser and you are done: two runtime dependencies, bundled TypeScript types (TypeScript 4.1.2 or newer), and both import and require entry points since the v9 exports map. The surprises are behavioural. parse() wraps your input in an extra root node, so the element you actually passed in is root.firstChild and set_content on the root is explicitly warned against. Comments are dropped unless you pass comment: true, and lowerCaseTagName is off because it costs performance, which means tagName comes back uppercase. The attributes object is documented as read-only, so mutate through setAttribute instead. One thing to check before upgrading: the GitHub releases page stops at v7.1.0 while npm is on 9.0.1, so the breaking changes across two majors are not written up anywhere obvious.
Patterns
Parse a string and query itparse-and-query
import { parse } from 'node-html-parser';
const root = parse('<ul id="list"><li>Hello World</li></ul>');
const list = root.querySelector('#list');
console.log(list.text); // Hello World
console.log(list.tagName); // ULtagName comes back uppercase unless you parse with lowerCaseTagName: true, which the docs note hurts performance.
Get at the actual first elementwrapper-node
const root = parse('<ul id="list"><li>a</li></ul>');
const ul = root.firstChild; // the <ul>, not root itselfparse() always adds a wrapper node, which is why root.toString() gives back your input and root.set_content() is warned against.
Use it from CommonJScommonjs
const { parse } = require('node-html-parser');
const root = parse(html);v9 ships an exports map with separate ESM and CJS builds, so both import styles resolve without a bundler shim.
Pull data out of a list of matchesselect-all
const links = root.querySelectorAll('a.result').map((el) => ({
href: el.getAttribute('href'),
title: el.text.trim(),
}));getAttribute returns undefined for a missing attribute, not null, which trips up code copied from browser DOM examples.
Choose the right text propertytext-variants
el.rawText; // escaped, as-is, fast
el.text; // unescaped, slower on first access
el.structuredText; // text with block structure preservedrawText can still contain entities such as &; text decodes them but is documented as slow the first time it runs.
Keep comments and fix loose markupkeep-comments
const root = parse(html, {
comment: true,
fixNestedATags: true,
parseNoneClosedTags: true,
});All three are off by default for speed, so comments simply do not exist in the tree unless you ask for them.
Read the contents of script or style tagsblock-text-elements
const root = parse(html, {
blockTextElements: { script: true, style: true, pre: true, noscript: false },
});
const json = root.querySelector('script[type="application/ld+json"]').rawText;Set the tag to false to have its text discarded during parsing, which is worth doing when you are only after the markup.
Edit the tree and print it back outmodify-and-serialise
const el = root.querySelector('.price');
el.setAttribute('data-checked', 'yes');
el.set_content('<span>19.99</span>');
console.log(root.toString());textContent is documented as the cheaper way to set plain text; set_content reparses the string you hand it.
Work with classesclass-list
const el = root.querySelector('.card');
el.classList.add('is-active');
el.classList.toggle('is-open');
if (el.classList.contains('sold-out')) {
el.remove();
}classList mirrors the browser API closely enough that browser habits carry over.
Insert markup around an elementinsert-nodes
const el = root.querySelector('#target');
el.insertAdjacentHTML('afterbegin', '<em>new</em>');
el.after('<hr>');
el.replaceWith('<p>replaced</p>');before() and after() are documented as not working on the root node, which is the wrapper parse() added.
Walk up and sideways from a matchtraverse
const cell = root.querySelector('td.total');
const row = cell.closest('tr');
const next = row.nextElementSibling;
const kids = row.children; // elements only, no text nodeschildNodes includes text and comment nodes; children is the element-only view, and mixing them up is the usual source of stray whitespace nodes.
Find where a node came from in the sourcesource-range
const el = root.querySelector('#target');
const [start, end] = el.range;
const original = html.slice(start, end);Useful for surgical string edits when you want to leave the rest of the document byte-identical instead of reserialising it.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| cheerio | npm | You want a jQuery-style API, a large ecosystem and documentation to match, and can afford a slower parse |
| parse5 | npm | You need a WHATWG-conformant tree, the same one a browser would build, including for messy real-world markup |
| htmlparser2 | npm | You want the fastest option and are happy working with a streaming event handler rather than a tree |
| jsdom | npm | You need a real DOM with scripts, events and layout-adjacent APIs rather than a static tree |