Cheerio is jQuery for the server: you hand it a string of HTML, it parses that into a DOM, and you query it with the $('selector') syntax every frontend developer already knows. It is the default HTML parser in the Node ecosystem, and its speed comes from what it doesn't do — no layout, no rendering, no JavaScript execution, no network stack.
That last part is where most people trip. This guide covers the whole working loop: installing the current release and the ESM/CJS import forms, loading HTML (including options most tutorials skip), selectors, extracting text and attributes, iterating with .each() and .map(), traversal, cleaning markup, TypeScript, a full fetch + Cheerio pipeline, scraping tables, and what to do when the page turns out to be client-rendered.
Key Takeaways
- Cheerio 1.2.0 requires Node 20.18.1+ and ships both ESM and CommonJS builds —
import * as cheerio from 'cheerio'andrequire('cheerio')both work - Do not install
@types/cheerio. Cheerio has bundled its own TypeScript types since 1.0; the DefinitelyTyped package is now an empty deprecated stub, and installing it is the single most common cause of broken Cheerio types - Cheerio never fetches anything on its own in the classic API — you bring
fetch, axios, or got. (The 1.xfromURLhelper is the exception, and it's built on undici.) - Cheerio never executes JavaScript — a React or Vue page gives you an empty shell no matter which selector you write
- A selection that matches nothing is empty, not null:
.text()returns''and.attr()returnsundefined. Check.lengthbefore trusting a result .text()on a multi-element selection concatenates all of them with no separator — the classic "why is my price$19.99$29.99$15.50" bug
What Cheerio is, and what it isn't
Cheerio implements a subset of the jQuery core API on top of a parsed document tree. It is a parser and a query/manipulation layer — nothing more, and that narrow scope is the whole point:
| Cheerio does | Cheerio does not |
| Parse HTML and XML into a DOM tree | Make HTTP requests (except the fromURL helper) |
| Query with CSS selectors | Execute JavaScript, ever |
| Read and write text, attributes, and markup | Compute layout, styles, or visibility |
| Traverse the tree (parents, siblings, children) | Handle cookies, sessions, or anti-bot challenges |
| Serialize a modified tree back to HTML | Render or screenshot anything |
Because it skips all of the right-hand column, it parses a typical page in single-digit milliseconds and uses tens of megabytes of RAM, versus a few hundred megabytes for a headless Chrome instance. When the data you want is present in the raw HTML response, nothing beats it. When it isn't, no amount of Cheerio configuration will conjure it — skip to the dynamic-content section.
Installing Cheerio in a Node.js project
npm install cheerio
Yarn and pnpm work the same way (yarn add cheerio, pnpm add cheerio). That single package is all you need for parsing — Cheerio bundles its own parsers (parse5 for HTML, htmlparser2 for XML) and, since 1.x, undici for the optional URL loader.
Version and runtime requirements. The current release is 1.2.0, and it declares "node": ">=20.18.1". If you're following a tutorial that pins cheerio@1.0.0-rc.12, that RC is years old — the stable 1.x line supersedes it and the import syntax changed along the way. Install the latest unless you have a specific reason not to:
npm install cheerio@latest
node -p "require('cheerio/package.json').version" # 1.2.0
Importing: ESM vs CommonJS
Cheerio publishes dual builds, so the correct import depends on your project, not on Cheerio:
// ESM — "type": "module" in package.json, or a .mjs file
import * as cheerio from 'cheerio';
// CommonJS — the default in a plain .js file
const cheerio = require('cheerio');
Two things to note. import cheerio from 'cheerio' (a default import) is wrong — Cheerio has no default export, and you'll get cheerio.load is not a function. Use the namespace form or destructure what you need: import { load } from 'cheerio'. And older tutorials show const cheerio = require('cheerio') followed by cheerio.default.load(...); that workaround belonged to the RC era and is unnecessary now.
There is also a slim entry point that drops parse5 and always parses with htmlparser2 — smaller and faster, marginally less spec-compliant on malformed markup:
import * as cheerio from 'cheerio/slim';
Reach for it in size-sensitive environments (edge functions, bundled workers). For ordinary scraping, the default entry point is the right call, because tolerating malformed markup is precisely what you need on real-world pages.
Loading HTML
cheerio.load() takes markup and returns the $ function bound to that document:
import * as cheerio from 'cheerio';
const $ = cheerio.load(`
<div class="container">
<h1>Welcome</h1>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="orange">Orange</li>
</ul>
</div>
`);
$('h1').text(); // 'Welcome'
$('#fruits li').length; // 2
$.html(); // serialize the whole document back to a string
Like a browser, load() wraps your input in <html>, <head>, and <body> if they're missing. The full signature is load(content, options, isDocument), and the options worth knowing:
// Parse a fragment without adding html/head/body wrappers
const $frag = cheerio.load('<li>One</li><li>Two</li>', null, false);
// Parse XML (RSS feeds, sitemaps) — switches to htmlparser2, keeps tag case,
// and stops second-guessing non-HTML element names
const $xml = cheerio.load(rssString, { xml: true });
$xml('channel > item > title').first().text();
// Set a base URI so .prop('href') and .prop('src') resolve to absolute URLs
const $page = cheerio.load(html, { baseURI: 'https://example.com/products/' });
$page('a.next').prop('href'); // 'https://example.com/products?page=2'
That baseURI option is worth a highlight: Cheerio has no absUrl() method like jsoup in Java, so without it you're resolving relative links by hand with new URL(href, base).href. Setting it once at load time removes a whole class of "why is my href just /product/123" bugs.
Loading from a buffer, stream, or URL
Cheerio 1.x ships convenience loaders on the main entry point (not on cheerio/slim):
import * as cheerio from 'cheerio';
import fs from 'node:fs';
// Sniffs the encoding from BOM, headers, and <meta charset> — the right way
// to read a legacy windows-1251 or shift_jis page from disk
const $ = cheerio.loadBuffer(fs.readFileSync('page.html'));
// Fetches and parses in one call: follows redirects, rejects non-2xx,
// and detects encoding. Built on undici.
const $live = await cheerio.fromURL('https://example.com');
fromURL is genuinely convenient for scripts and one-offs. For production scrapers you'll usually still want your own HTTP layer, because that's where custom headers, retries, proxies, and rate limiting live — see the pipeline section below.
Selecting elements with CSS selectors
Cheerio selection is CSS, powered by cheerio-select. If you know querySelectorAll, you already know this:
| Goal | Selector |
| By class / id | .price, #main |
| Attribute presence / value | img[data-src], a[rel="nofollow"] |
| Attribute prefix / suffix / contains | a[href^="/product"], img[src$=".webp"], a[href*="utm_"] |
| Direct child vs any descendant | ul.menu > li vs ul.menu li |
| Multiple selectors | h1, h2, .headline |
| Structural | tr:nth-child(2n), li:first-child, td:last-child |
| Element containing text | h2:contains("Reviews") |
| Element that has a descendant | div:has(img.hero) |
| Negation | li:not(.sold-out) |
Cheerio also supports the jQuery positional extensions :first, :last, :eq(n), and :gt(n)/:lt(n). What it does not support is anything requiring layout or a live browser — :visible, :hidden, :checked on user interaction, :focus. There's no rendering engine to ask, so those either throw or silently match nothing. There's no XPath support either; nearly every scraping XPath has a direct CSS equivalent (//div[@class='x'] → div.x), and our XPath cheat sheet covers the translations that aren't obvious.
The most important behavioural difference from other parsers: an empty selection is never null.
$('.does-not-exist').length; // 0
$('.does-not-exist').text(); // '' — not null, not an error
$('.does-not-exist').attr('href'); // undefined
$('.does-not-exist').each(() => {}) // runs zero times, no crash
This is friendly right up until a site redesign breaks your selectors and your scraper cheerfully writes a thousand empty rows. Assert on .length at the top of every extraction path.
Extracting text and attributes
const $ = cheerio.load(`
<div class="product" data-id="123" data-price="29.99">
<h2 class="name">Wireless Mouse</h2>
<img src="/img/mouse.jpg" alt="Wireless Mouse">
<a href="/product/123" class="link">View</a>
</div>
`);
$('.name').text(); // 'Wireless Mouse'
$('.name').html(); // inner HTML of the element
$('.link').attr('href'); // '/product/123'
$('.product').attr('data-id'); // '123'
$('.product').data('price'); // 29.99 — .data() parses data-* and coerces types
$('img').prop('outerHTML'); // '<img src="/img/mouse.jpg" alt="Wireless Mouse">'
$('img').prop('tagName'); // 'IMG'
Three gotchas that account for most extraction bugs:
.attr() reads only the first match. On a multi-element selection you get the first element's attribute and no warning:
$('li').attr('data-category'); // 'electronics' — just the first <li>
$('li').map((i, el) => $(el).attr('data-category')).get();
// ['electronics', 'books', 'clothing'] — all of them
.text() concatenates across a multi-element selection, with no separator at all:
$('.price').text(); // '$19.99$29.99$15.50' — almost never what you want
$('.price').first().text(); // '$19.99'
$('.price').map((i, el) => $(el).text().trim()).get(); // ['$19.99', '$29.99', '$15.50']
Missing attributes are undefined, not ''. Use a default rather than letting undefined reach your database:
const alt = $('img').attr('alt') ?? '';
const inStock = $('.product').attr('data-in-stock') === 'true';
Boolean attributes follow the HTML rule — present means the attribute exists (value '' or its own name), absent means undefined:
const $box = cheerio.load('<input type="checkbox" checked disabled>')('input');
$box.attr('checked') !== undefined; // true
Making links absolute
Either set baseURI at load time (above) and read with .prop('href'), or resolve explicitly:
const base = 'https://example.com/products/';
const links = $('a[href]')
.map((i, el) => new URL($(el).attr('href'), base).href)
.get();
The new URL() form is worth internalizing — it handles ../, protocol-relative //cdn.example.com/x, and query strings correctly, which naive string concatenation does not.
Looping through elements
.each() is the workhorse. Its callback receives (index, element), where element is a raw DOM node, not a Cheerio object — wrapping it with $(element) is what unlocks the API:
$('.product').each((index, element) => {
const $el = $(element);
console.log(index, $el.find('.name').text().trim());
});
If you write .each(function () { $(this)... }) with a classic function expression, this is bound to the element too. Arrow functions don't bind this, so with arrows you must use the element parameter. Pick one style and stay with it; mixing them is a reliable source of $(this) is not a function.
Returning false from the callback breaks out of the loop early — handy when you only need the first N matches on a long page.
.map().get() is usually the better tool when you're building an array, because it's a single expression and it composes:
const products = $('.product')
.map((i, el) => {
const $el = $(el);
return {
id: $el.attr('data-id'),
name: $el.find('.name').text().trim(),
price: parseFloat($el.find('.price').text().replace(/[^0-9.]/g, '')),
url: $el.find('a').attr('href'),
};
})
.get(); // .get() converts the Cheerio object into a plain array
Forgetting .get() is the single most common Cheerio mistake — without it you have a Cheerio-wrapped collection, so products.filter(...) and JSON.stringify(products) both behave strangely.
Two more iteration forms are worth knowing. .filter() narrows a selection before you iterate, and .toArray() gives you plain nodes so you can use for...of — which matters whenever the body is async, since .each() will not await your callback:
// Filter first, then iterate
$('.product')
.filter((i, el) => $(el).attr('data-available') === 'true')
.each((i, el) => console.log($(el).text()));
// Async work per element — .each() cannot await, for...of can
for (const el of $('.product a').toArray()) {
const detail = await fetchDetailPage($(el).attr('href'));
// ...
}
Selecting by class and by id works identically here — $('.product') and $('#main') both return selections you can .each() over. IDs should be unique, so for those you normally skip the loop entirely: $('#main').text().
Traversing the tree
Selectors get you to a container; traversal gets you around inside it. This is what makes Cheerio robust against markup that has no useful classes:
$el.find('.price') // descendants matching a selector
$el.children('li') // direct children only
$el.parent() // immediate parent
$el.closest('.card') // nearest ancestor matching a selector
$el.next() / $el.prev() // adjacent siblings
$el.siblings('.tag') // all siblings, optionally filtered
$el.eq(2) // the third element in a selection
$el.first() / $el.last()
The classic use is label-to-value extraction on spec tables, where the value cell has no class of its own:
const weight = $('th:contains("Weight")').next('td').text().trim();
Manipulating and cleaning markup
Cheerio's setters are the same jQuery methods, and they're most useful for cleaning before extracting — stripping the noise that would otherwise contaminate .text():
// Remove scripts, styles, and nav chrome before taking the article text
$('script, style, noscript, nav, footer, .ad').remove();
const articleText = $('article').text().replace(/\s+/g, ' ').trim();
// Setters
$('a').attr('rel', 'nofollow');
$('.price').text('Sold out');
$('.banner').addClass('hidden').removeAttr('style');
$('<p>Appended</p>').appendTo('#content');
const cleaned = $.html(); // serialize the modified document
$('script, style').remove() before .text() is close to mandatory on real pages — inline scripts and CSS are text nodes, and without removing them your "article text" arrives full of JSON blobs and CSS rules.
The reverse trick is also useful: when a page ships its data as JSON inside a <script> tag (very common with Next.js and Nuxt, and with application/ld+json product markup), don't parse the HTML at all — grab the script contents and parse them as JSON:
const raw = $('script[type="application/ld+json"]').first().text();
const product = JSON.parse(raw);
That path is more stable than any selector chain, because structured data changes far less often than presentational markup.
Scraping a table
Tables are the case where Cheerio's compactness really shows. Read the headers, then map each row into an object keyed by them:
const headers = $('table#prices thead th')
.map((i, el) => $(el).text().trim())
.get();
const rows = $('table#prices tbody tr')
.map((i, tr) => {
const cells = $(tr).find('td').map((j, td) => $(td).text().trim()).get();
if (cells.length !== headers.length) return null; // skip spacer/summary rows
return Object.fromEntries(headers.map((h, k) => [h, cells[k]]));
})
.get()
.filter(Boolean);
Two real-world caveats. Many pages omit <thead>/<tbody> in the source — browsers insert <tbody> when rendering, and so does Cheerio's HTML parser, so tbody tr usually still works; if it doesn't, fall back to table#prices tr. And colspan/rowspan cells will misalign a naive index-based mapping, so check cells.length as above rather than trusting position.
Using Cheerio with TypeScript
Start by not installing @types/cheerio. Cheerio has shipped its own type declarations since 1.0, and the DefinitelyTyped package is now a published stub whose own description reads: "cheerio provides its own type definitions, so you do not need this installed." It types the 0.22 API, so if it's in your devDependencies it will shadow the real types and produce errors that make no sense against the code you actually wrote. Remove it:
npm uninstall @types/cheerio
npm install cheerio
With that out of the way, Cheerio is fully typed with no configuration:
import * as cheerio from 'cheerio';
import type { CheerioAPI } from 'cheerio';
import type { Element } from 'domhandler';
interface Product {
name: string;
price: number;
url?: string;
}
function extractProducts(html: string): Product[] {
const $: CheerioAPI = cheerio.load(html);
return $('.product')
.map((i: number, el: Element): Product => {
const $el = $(el);
return {
name: $el.find('.name').text().trim(),
price: parseFloat($el.find('.price').text().replace(/[^0-9.]/g, '')) || 0,
url: $el.find('a').attr('href'), // string | undefined — strict mode is happy
};
})
.get();
}
The types that matter in practice:
| Type | Import from | What it is |
CheerioAPI | cheerio | The $ function itself |
Cheerio<T> | cheerio | A selection, e.g. Cheerio<Element> |
CheerioOptions | cheerio | The options bag for load() |
Element, AnyNode | domhandler | Individual DOM nodes |
The one migration trap: cheerio.Element no longer exists. Older code and older tutorials write (el: cheerio.Element), which was valid against @types/cheerio and fails now. Node types come from domhandler (a Cheerio dependency, so it's already in your tree) — import Element from there. Since .attr() returns string | undefined by design, strict: true in tsconfig.json is genuinely useful here: it forces you to handle the missing-attribute case at compile time instead of discovering it in your output.
The fetch + Cheerio pipeline
Cheerio parses; something else fetches. Node 18+ has a global fetch(), so for most scrapers you need no HTTP dependency at all:
import * as cheerio from 'cheerio';
async function scrape(url) {
const res = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
redirect: 'follow', // the default — 301/302/307/308 are followed
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
const $ = cheerio.load(await res.text(), { baseURI: res.url });
return $;
}
A few details that matter once this runs unattended:
- Set a real
User-Agent. Node's defaultundiciUA is blocked by a lot of sites outright, and it's the first thing to change when everything works in the browser but not in your script. - Redirects are followed by default.
res.urlis the final URL after the chain, which is exactly what you want forbaseURI. Useredirect: 'manual'only when you need to inspect theLocationheader yourself — for example to detect that a product URL now redirects to a category page, which usually means the item was delisted. fetchdoesn't time out on its own.AbortSignal.timeout()is the built-in fix; without it a stalled connection hangs the job indefinitely.res.text()assumes UTF-8. On a legacy page in another encoding you'll get mojibake — readres.arrayBuffer()and hand it tocheerio.loadBuffer(Buffer.from(buf)), which sniffs the real encoding.- Retry 5xx and network errors, not 4xx. A 403 on retry is the same 403; retrying a 429 tightly makes the rate limiting worse. Exponential backoff with jitter, and a cap.
The broader Node.js scraping picture — concurrency, queues, when to reach for Crawlee — is covered in our JavaScript and Node.js scraping guide.
JavaScript-rendered pages: Cheerio's hard limit
Cheerio parses the HTML the server sent. If a page builds its content client-side, that HTML is a near-empty shell, and no selector will find data that was never in the response.
Diagnose it in ten seconds before writing any code: curl -s https://example.com/page | grep -i "some text you can see". If the text isn't in the output, Cheerio can't reach it either. Your options, in increasing order of weight:
- Find the underlying API. DevTools → Network → Fetch/XHR. If the page loads its own data over JSON, request that endpoint directly — it's faster and more stable than any HTML parsing, and no parser is involved at all.
- Check for embedded JSON. Next.js ships
<script id="__NEXT_DATA__">, Nuxt useswindow.__NUXT__, and many stores emitapplication/ld+json. Cheerio grabs the script text andJSON.parsedoes the rest — see the snippet above. - Render with a headless browser. Puppeteer or Playwright drives real Chrome, and you can still hand
await page.content()to Cheerio for the parsing. That combination is normal architecture, not a workaround. Our headless browser guide covers when the cost is justified. - Use a rendering API. Fetch through a service that runs the browser for you and returns final HTML, then parse with Cheerio exactly as before.
Cheerio vs jsdom vs Puppeteer
| Cheerio | jsdom | Puppeteer / Playwright | |
| What it is | HTML parser + jQuery API | Full DOM implementation in JS | Real browser automation |
| JavaScript execution | None | Limited (no layout, partial browser APIs) | Full |
| Speed | Fastest — milliseconds | 10–50× slower than Cheerio | Slowest — full page load |
| Memory per page | Tens of MB | Hundreds of MB | Hundreds of MB + browser |
| Interaction (clicks, scroll) | No | No | Yes |
| Best for | Static HTML, feeds, cleaning markup | Testing DOM code, light script execution | SPAs, logins, infinite scroll, anti-bot flows |
The short version: jsdom is a testing tool that happens to parse HTML, not a faster headless browser. If Cheerio isn't enough because the page needs scripts to run, you almost always want a real browser, not jsdom. The full ecosystem — including which libraries are still maintained in 2026 — is in our JavaScript scraping libraries comparison.
Cheerio at scale, without running browsers
Splitting fetch from parse is the productive pattern, and it means the hard part — rendering, proxies, retries, blocks — is a swappable layer while your Cheerio code stays untouched. WebScraping.AI renders pages in a real browser behind rotating proxies and returns HTML from a single GET, so the only thing that changes in the code above is the URL you fetch:
import * as cheerio from 'cheerio';
const target = 'https://example.com/spa-products';
const api = `https://api.webscraping.ai/html?api_key=${process.env.WSAI_KEY}` +
`&url=${encodeURIComponent(target)}&js=true`;
const html = await fetch(api).then((r) => r.text());
const $ = cheerio.load(html, { baseURI: target }); // every selector unchanged
const products = $('.product').map((i, el) => $(el).find('.name').text()).get();
No browser processes to supervise, no proxy pool to rotate, no stealth patches to keep current. When you'd rather not maintain selectors at all, the /ai/fields endpoint returns structured JSON from plain-English field descriptions, which survives the redesigns that break CSS selectors — the difference that matters most for long-running jobs like price monitoring. The API reference documents the rest of the parameters (proxy, country, wait_for, device, timeout).
Frequently asked questions
Is Cheerio good for web scraping?
Yes, for static HTML — it's the fastest and simplest option in Node, and it's what you should reach for first. Test with curl before writing code: if your data is in the raw response, Cheerio is the right tool, and if it isn't, no Cheerio configuration will change that.
Can Cheerio handle JavaScript-rendered pages?
No. It has no JavaScript engine and never runs scripts, so client-rendered content simply isn't in the document it parses. Look for the underlying JSON API or embedded __NEXT_DATA__ first; otherwise render with a headless browser or a rendering API and hand the resulting HTML to Cheerio.
Do I need @types/cheerio for TypeScript?
No — remove it if you have it. Cheerio has bundled its own type definitions since 1.0, and the DefinitelyTyped package is now a deprecated stub that types the ancient 0.22 API. Having both installed is the most common cause of confusing Cheerio type errors.
Does Cheerio make HTTP requests?
Not in the classic API — you pair it with fetch, axios, or got. Cheerio 1.x does add a cheerio.fromURL(url) helper (built on undici) that fetches, follows redirects, and detects encoding, which is handy for scripts. Production scrapers usually keep their own HTTP layer, since that's where headers, retries, and proxies belong.
Why does .text() return everything mashed together?
Because .text() on a selection concatenates the text of every matched element with no separator. Use .first().text() for one value, or .map((i, el) => $(el).text().trim()).get() for an array of them.
Does Cheerio support XPath?
No — CSS selectors only, plus the jQuery traversal methods. Nearly every scraping XPath translates directly (//a[@class='next'] → a.next); for the rare axis CSS can't express, select something nearby and traverse with .parent(), .closest(), or .next().
Cheerio vs Puppeteer — which should I use? They solve different problems. Cheerio parses HTML you already have; Puppeteer drives a browser to produce HTML. Static pages → Cheerio alone, roughly 10–50× cheaper. Client-rendered pages and login flows → a browser (or a rendering API) to get the HTML, then Cheerio to parse it. Using both together is the standard architecture.
Why is my selector returning nothing?
Three usual causes, in order of likelihood: the content is JavaScript-rendered and was never in the HTML; the site serves different markup to non-browser User-Agents (set a real one); or the selector targets a class that a redesign changed. Print $.html().length and search the raw HTML for your target text before debugging the selector itself.