cheerio
Cheerio parses an HTML or XML string into a tree and hands you a jQuery-shaped API for querying and editing it. You call cheerio.load(html) and get back a $ function, then use the selectors and methods you already know from jQuery: $('a.link').attr('href'), .text(), .find(), .each(), .append(), .remove(). When you are done you serialize the tree back to a string with $.html(). There is no browser involved: no JavaScript execution, no CSS layout, no network requests unless you ask for them. It parses with parse5 by default for spec-correct HTML, and can switch to htmlparser2 for a faster forgiving parse or for XML documents. That combination is why it sits under most Node scrapers, HTML post-processing build steps, and server-render test helpers.
For server-side HTML that arrives as text, cheerio is still the default answer and the 1.x line is actively maintained. The one question to settle before you install it is whether the data you want is in the raw HTML at all, because cheerio will never run the JavaScript that puts it there.
Use it if
- You are pulling data out of server-rendered HTML and already think in CSS selectors, so $('table tbody tr') beats writing a regex or a hand-rolled tree walk
- You need to rewrite HTML you did not author: injecting tracking pixels into email templates, inlining assets in a static site build, or rewriting relative links to absolute ones before publishing
- You want to assert on the HTML output of an SSR framework or template engine in unit tests without booting a headless browser
- You are parsing XML feeds such as RSS or Atom and want the same selector API with xml: true instead of a separate XML library
- Throughput matters: parsing thousands of documents per minute in a worker is fine here, and would be hopeless with a real browser per page
- The content you want only exists after JavaScript runs. Cheerio never executes scripts, so a React or Vue page served as an empty shell gives you an empty result and no error. That is a Playwright or Puppeteer job
- You need actual DOM semantics: getBoundingClientRect, computed styles, events, MutationObserver, or code that expects document and window. Cheerio's node objects only resemble DOM nodes, they are not DOM nodes. jsdom or linkedom fit better
- You are shipping this to a browser bundle. The full build is roughly 116 KB gzipped and pulls in parse5, htmlparser2, and undici; the cheerio/slim entry point is much smaller but drops fromURL, loadBuffer, and the streaming helpers
- You are stuck on Node 18 or older. The 1.x line declares engines node >=20.18.1, and the older 1.0.0-rc.12 that still installs on old Node has a different entry style and no longer gets fixes
- You expect all of jQuery. There is no .on(), no .animate(), no .ajax(), no .css() computation. Cheerio implements the traversal and manipulation subset only, and people rediscover that gap mid-migration
- You only need one field out of a page and care about cold start. node-html-parser or a targeted regex over well-known markup will start faster and pull in nothing
Setup reality
npm install cheerio is quick and there is no native code, but it brings 11 direct dependencies including undici, which is a full HTTP client that exists only so cheerio.fromURL works. The 1.0 release changed the entry shape: there is no default export any more, so it is import * as cheerio from 'cheerio' or const cheerio = require('cheerio'), then cheerio.load(html). The old static shortcuts cheerio.html() and cheerio.text() were removed, which is why copy-pasted snippets from tutorials written against 1.0.0-rc.12 throw immediately. Node 20.18.1 or newer is enforced through engines. The package ships dual ESM and CommonJS builds plus separate cheerio/slim and cheerio/utils entry points, so bundler config that predates package exports may resolve the wrong file. If you use TypeScript, selectors are typed through SelectorType and overly dynamic selector strings will need a cast.
Patterns
Load HTML and read values with selectorsload-and-select
import * as cheerio from 'cheerio';
const $ = cheerio.load('<ul id="fruits"><li class="apple">Apple</li><li class="pear">Pear</li></ul>');
$('.apple').text(); // 'Apple'
$('li').length; // 2
$('#fruits li').first().attr('class'); // 'apple'
$.html(); // full document stringThere is no default export in 1.x. Importing cheerio and calling cheerio('...') directly throws; you must call cheerio.load first. load also wraps fragments in html/head/body unless you pass isDocument false as the third argument.
Pull a list of records out in one call with $.extractextract-structured-data
import * as cheerio from 'cheerio';
const $ = cheerio.load(html);
const data = $.extract({
rows: [
{
selector: 'li.row',
value: {
url: { selector: 'a', value: 'href' },
name: 'a',
price: '.p',
},
},
],
pageTitle: 'h1',
});
// { rows: [{ url: '/x', name: 'X', price: '9' }, ...], pageTitle: '...' }A bare string value means text content. To read an attribute or outerHTML you need the object form with an explicit value key; writing value: { href: 'href' } silently returns empty objects instead of erroring.
Turn a selection into a plain JavaScript arraymap-to-array
const hrefs = $('a').map((i, el) => $(el).attr('href')).get();
// nested rows: use toArray, not map().get()
const rows = $('tbody tr')
.toArray()
.map((tr) => $(tr).find('td').toArray().map((td) => $(td).text().trim()));map().get() flattens one level, so a table read with nested map().get() comes back as one long flat array of cells. Use toArray() plus a normal Array.map when the result should stay nested.
Load a document straight from a URLfetch-and-parse
import * as cheerio from 'cheerio';
const $ = await cheerio.fromURL('https://example.com', {
requestOptions: {
headers: { 'user-agent': 'my-crawler/1.0' },
},
});
console.log($('h1').text());fromURL follows redirects, rejects non-2xx responses, and sniffs the charset for you. It only exists in the full build, not in cheerio/slim, and it is the reason undici is a dependency.
Edit the tree and write it back outmodify-and-serialize
const $ = cheerio.load(template);
$('script').remove();
$('h1').addClass('headline').text('New title');
$('body').append('<footer>generated</footer>');
$('img').each((i, el) => $(el).attr('loading', 'lazy'));
const out = $.html();Every method mutates the loaded tree in place, so there is no immutable mode and no undo. $.html() re-serializes the whole document including the html/head/body wrapper load added; use $('body').html() or .prop('outerHTML') for fragments.
Parse RSS or other XML with the same APIparse-xml-feed
const $ = cheerio.load(rssText, { xml: true });
const items = $('item')
.toArray()
.map((el) => ({
title: $(el).find('title').first().text(),
link: $(el).find('link').first().text(),
}));
$.xml(); // serialize back as XMLxml: true switches to htmlparser2 with case-sensitive tag names and no html/body wrapper. Namespaced tags such as content:encoded need an escaped selector like $('content\\:encoded') because the colon is a CSS pseudo-class character.
Rewrite relative links to absolute onesresolve-relative-urls
const base = 'https://example.com/blog/';
$('a[href], img[src]').each((i, el) => {
const $el = $(el);
const attr = $el.is('a') ? 'href' : 'src';
const raw = $el.attr(attr);
if (raw) $el.attr(attr, new URL(raw, base).href);
});Cheerio stores attributes exactly as written and does not resolve anything, unlike a browser where element.href is already absolute. Guard against missing attributes; new URL(undefined, base) throws.
Walk the tree relative to a found elementtraverse-relative
const price = $('span:contains("Price")')
.first()
.closest('.product')
.find('.amount')
.text()
.trim();
$('h2').each((i, el) => {
const body = $(el).nextUntil('h2').text();
});:contains is a jQuery extension, not standard CSS, and it matches descendants too, so a wrapper div containing the text also matches. Anchor with .first() or a tighter selector before traversing.
Read data attributes with type coercionread-data-attributes
const $ = cheerio.load('<div id="x" data-user-id="7" data-tags="[1,2]"></div>');
$('#x').data(); // { userId: 7, tags: [1, 2] }
$('#x').data('userId'); // 7
$('#x').attr('data-user-id'); // '7' (always a string)data() parses JSON and numbers like jQuery does, so an id such as data-id="0012345" comes back as the number 12345 and loses the leading zeros. Use attr() when you need the literal string.
Use the smaller htmlparser2-only buildslim-build
import { load } from 'cheerio/slim';
const $ = load('<p>hi');
$.html(); // '<p>hi</p>'cheerio/slim exports only load, contains, and merge, and parses with htmlparser2 instead of parse5. It is faster and much smaller, but it is more forgiving than the HTML spec on malformed markup, and fromURL and the stream helpers are not there.
Parse a file as a stream instead of a stringstream-large-documents
import * as fs from 'node:fs';
import * as cheerio from 'cheerio';
const writable = cheerio.decodeStream({}, (err, $) => {
if (err) throw err;
console.log($('h1').text());
});
fs.createReadStream('big-page.html').pipe(writable);decodeStream takes buffers and sniffs the encoding; stringStream takes strings and skips the sniffing. Neither one streams results out: the callback still fires once at the end with the whole document in memory.
Fail loudly when a selector matches nothingdetect-empty-selection
function requireOne($sel, label) {
if ($sel.length !== 1) {
throw new Error(`${label}: expected 1 match, got ${$sel.length}`);
}
return $sel;
}
const title = requireOne($('h1.title'), 'title').text();An empty selection returns '' from .text() and undefined from .attr(), never an error, so a site redesign turns into empty rows in your database instead of a failed job. Assert on .length at the boundaries of a scraper.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jsdom | npm | You need a real DOM implementation with document, window, and optional script execution instead of a jQuery-style wrapper |
| node-html-parser | npm | You want a much lighter and faster parser and are happy with a small querySelector-style API |
| playwright | npm | The markup you need is produced by client-side JavaScript, so the page has to actually run in a browser |
| linkedom | npm | You want standard DOM methods and a lighter footprint than jsdom for server-side HTML work |