Cheerio Web Scraping Tutorial
To scrape a page with Cheerio, download its HTML with fetch, pass it to cheerio.load(), and read elements with $("css selector").text() or .attr(). When results come back empty, the cause is almost always one of three: the selector does not match the HTML the server sent, the content is added later by JavaScript (Cheerio runs none), or you were served an error, consent or block page instead of the one you wanted. Check res.status and $(selector).length first. If one IP is not enough, ProxyField residential proxies go into that fetch call, from $1.95/GB, starting with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 9 sections
- What Cheerio does, and what it doesn't
- Install and the first scraper
- Why Cheerio returns empty results, and how to find the cause
- Select elements and read values
- Extract a whole object with extract()
- Scrape several pages
- Cheerio vs jsdom vs a headless browser
- Add a proxy to the fetch that feeds Cheerio
- Cheerio Web Scraping Tutorial FAQ
What Cheerio does, and what it doesn't
Cheerio builds a document tree from an HTML string and gives you a subset of jQuery's API to search and read it. Version 1.2.0 is current on the npm registry (MIT license). The package declares Node.js >=20.18.1, while the Cheerio docs say it runs on Node.js 22.19 or later: use Node.js 22.19+ and you satisfy both. The docs also state the limit that causes most empty results: no loading of external resources and no JavaScript execution.
| Cheerio does | Cheerio does not |
|---|---|
| Parse HTML and XML, including messy markup | Run the page's JavaScript or render CSS |
| Select with CSS selectors in jQuery syntax | Click, scroll or fill forms |
| Read text, attributes and HTML; walk parents and siblings | Wait for content that loads later |
Fetch and parse a URL in one call (fromURL) | Rotate IPs, retry or queue a crawl |
Extract a nested object in one call (extract) | Throw an error when a selector matches nothing |
If you already know jQuery, you know most of Cheerio. The difference is where it runs: jQuery works on the live page in a browser, and Cheerio works on HTML you downloaded, on a server or in a script.
Install and the first scraper
npm install cheerioCheerio 1.x has no default export, so import cheerio from "cheerio" fails with SyntaxError: The requested module 'cheerio' does not provide an export named 'default'. Import it as a namespace:
// countries.mjs (Node.js 22)
import * as cheerio from "cheerio";
const url = "https://www.scrapethissite.com/pages/simple/";
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${res.url}`);
const $ = cheerio.load(await res.text());
const countries = $("div.country").map((_, el) => ({
name: $(el).find("h3.country-name").text().trim(),
capital: $(el).find("span.country-capital").text().trim(),
population: Number($(el).find("span.country-population").text()),
})).get();
console.log(countries.length, countries[0]);Run it with node countries.mjs. On 2026-10-27, with Cheerio 1.2.0 on Node.js 22, it printed:
250 { name: 'Andorra', capital: 'Andorra la Vella', population: 84000 }The page is a practice sandbox with 250 countries, each in a div.country. Node.js 18 and later ship fetch, so you need no HTTP library for simple jobs; await it, or you parse a Promise instead of HTML. With CommonJS, use const cheerio = require("cheerio");.
Why Cheerio returns empty results, and how to find the cause
A selector that matches nothing does not throw. $(sel).length is 0, .text() returns "" and .attr() returns undefined, so a broken scraper runs without errors and saves empty fields. Work through these checks in order:
| # | Check | Code | What it tells you |
|---|---|---|---|
| 1 | Did the request succeed? | res.ok, res.status, res.url | A 403, 429 or 5xx, or a redirect to a login or consent page, gives HTML without your data |
| 2 | What did the server send? | console. | A challenge page, a cookie-consent wall or an empty <div id="root"></div> shows up here |
| 3 | Does the selector match? | $("div.country").length | 0 means the selector or the HTML is wrong; test each part of the selector on its own |
| 4 | Is the data in the HTML at all? | search html for a value you see on screen, e.g. html.includes("Andorra") | false means JavaScript adds it after load (step 5) |
| 5 | Where does the data come from? | DevTools, Network tab, Fetch/XHR | A JSON request, or JSON embedded in a <script> tag |
Step 4 is the one DevTools hides: the Elements panel shows the live DOM after scripts ran, while Cheerio sees only the HTML the server sent. Compare with View Source (Ctrl+U), not with Elements. Browser-state selectors such as :hover or :visited also never match in Cheerio.
A real example. The sandbox page /pages/ajax-javascript/ lists Oscar-winning films, but loads them with JavaScript after you click a year:
import * as cheerio from "cheerio";
const res = await fetch("https://www.scrapethissite.com/pages/ajax-javascript/");
const html = await res.text();
const $ = cheerio.load(html);
console.log(res.status); // 200: the request itself worked
console.log($("tr.film").length); // 0: no film rows in the server's HTML
console.log(JSON.stringify($("tr.film td.film-title").text())); // "" (no error)
console.log($("tr.film").attr("class")); // undefined (no error)The rows are not in the HTML, so no selector can find them. The Network tab shows the page calling ?ajax=true&year=2015, which returns JSON; call it directly and skip Cheerio:
const films = await (await fetch(
"https://www.scrapethissite.com/pages/ajax-javascript/?ajax=true&year=2015")).json();
console.log(films.length, films[0].title.trim()); // 16 SpotlightBoth scripts printed these values on 2026-10-27. The three ways out of a JavaScript-built page, from lightest to heaviest:
-
Call the JSON request
The page makes, as above. See scraping hidden APIs.
-
Read embedded JSON
Many sites put their data in a
<script>tag:JSON.parse($("#__NEXT_DATA__").text())on Next.js sites, or$('script[type="application/ld+json"]').text(). -
Render the page
With Puppeteer or Playwright, or emulate the DOM with jsdom, then pass
await page.content()tocheerio.load()if you prefer Cheerio's API for the parsing. More in scraping dynamic web pages and Cheerio vs Puppeteer.
If check 1 or 2 shows a 403, a 429 or a challenge page, the site is refusing your IP or your rate: slow down, send a normal User-Agent and Accept-Language, and spread requests over several IPs (add a proxy).
Select elements and read values
$("h3.country-name").first().text().trim(); // first match, as text
$("div.country").length; // how many matched
$("a").attr("href"); // attribute of the first match
$("div.country").eq(2).find("span").text(); // the third card's spans
$("span.country-capital").map((_, el) => $(el).text()).get(); // array of strings
$('meta[property="og:title"]').attr("content"); // attribute selector
$("h3").parent().attr("class"); // walk up the treePoints that trip people up:
.text()on several elements joins all their text. Use.map(...).get()or.each()to get one value per element..attr()reads only the first element. For every link, map over the set:$("a").map((_, a) => $(a).attr("href")).get().- Text includes whitespace from the HTML. Call
.trim(), as above. - Relative URLs stay relative. Turn them into absolute ones with
new URL(href, pageUrl).href. - Selectors are CSS, not XPath. Cheerio has no XPath; CSS covers almost every case, and HTML scraping shows the selectors that do the work.
Extract a whole object with extract()
Cheerio 1.x includes extract(), which takes a map of selectors and returns an object. A one-element array means "every match":
const $ = await cheerio.fromURL("https://www.scrapethissite.com/pages/simple/");
const { countries } = $.extract({
countries: [{
selector: "div.country",
value: {
name: { selector: "h3.country-name", value: (el) => $(el).text().trim() },
capital: "span.country-capital",
flag: { selector: "h3 i", value: "class" },
},
}],
});
// countries.length -> 250
// countries[0] -> { name: 'Andorra', capital: 'Andorra la Vella', flag: 'flag-icon flag-icon-ad' }A plain string selector returns the element's text, value: "class" (or any attribute name) returns that attribute, and a function lets you clean the value. fromURL() fetches and parses in one call; use fetch plus load() when you need to check the status, send custom headers, use a proxy or retry.
Scrape several pages
Follow the Next link until there is none, and keep a set of visited URLs so that a link back to an earlier page can't loop:
import * as cheerio from "cheerio";
import { setTimeout as sleep } from "node:timers/promises";
let url = "https://www.scrapethissite.com/pages/forms/?page_num=1";
const seen = new Set();
const teams = [];
while (url && !seen.has(url)) {
seen.add(url);
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
const $ = cheerio.load(await res.text());
$("tr.team").each((_, tr) => {
teams.push({
name: $(tr).find("td.name").text().trim(),
year: Number($(tr).find("td.year").text()),
wins: Number($(tr).find("td.wins").text()),
});
});
const next = $('a[aria-label="Next"]').attr("href");
url = next ? new URL(next, url).href : null;
await sleep(2000); // be polite
}
console.log(seen.size, "pages,", teams.length, "teams"); // 24 pages, 582 teamsThe site lists 582 teams on 24 pages of up to 25 rows; the last page is partial. Other ways to page through a list (page numbers, "Load more", API cursors) are in web scraping pagination.
When you know the page URLs in advance, fetch a few at a time instead of one by one. p-limit (npm install p-limit) caps how many requests run at once:
import pLimit from "p-limit";
const limit = pLimit(3); // at most 3 requests at a time
const urls = Array.from({ length: 24 }, (_, i) =>
`https://www.scrapethissite.com/pages/forms/?page_num=${i + 1}`);
const pages = await Promise.all(urls.map((url) => limit(async () => {
const $ = cheerio.load(await (await fetch(url)).text());
return $("tr.team td.name").map((_, td) => $(td).text().trim()).get();
})));
console.log(pages.flat().length, "teams");Tables have their own shortcuts, covered in how to scrape a table from a website.
Cheerio vs jsdom vs a headless browser
| Feature | Cheerio | jsdom | Puppeteer or Playwright |
|---|---|---|---|
| What it is | HTML parser with a jQuery-like API | A DOM implementation in Node.js | A real browser you control |
| Runs page scripts | No | Optionally, with limits | Yes |
| Speed and memory | Fastest, lightest | Slower, heavier | Slowest, heaviest |
| API | $(), .find(), .text() | Standard DOM: querySelector, textContent | Browser API plus page actions |
| Use it for | Static pages, high volume | Code that expects a browser DOM | Pages built by JavaScript, clicks, scrolling |
Crawlee's CheerioCrawler wraps Cheerio in a crawler with a request queue, retries and proxy rotation; its PlaywrightCrawler and PuppeteerCrawler use the same API with a browser. The wider Node.js picture is in web scraping with JavaScript and Node.js.
Add a proxy to the fetch that feeds Cheerio
Cheerio parses whatever HTML you hand it, so the proxy goes into the request that downloads it; for a proxy, fetch the page yourself rather than with fromURL(). With undici, the HTTP library behind Node's fetch:
import { fetch, ProxyAgent } from "undici"; // npm install undici
import * as cheerio from "cheerio";
const dispatcher = new ProxyAgent("http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000");
const res = await fetch("https://www.scrapethissite.com/pages/simple/", { dispatcher });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const $ = cheerio.load(await res.text());gate.proxyfield.io:7000 is the ProxyField gateway; <login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. The gateway can give each request a new IP, or keep one IP for a sticky session from 30 s to 36 h. Use fetch from the same undici package as the ProxyAgent, as above, so the two versions match. Axios, got and node-fetch take a proxy agent instead; all of them are on the Node.js proxy setup page.
A Cheerio scraper downloads only HTML, so it is light on traffic: at an assumed 150 KB per page, that is about 6,700 pages per GB, or about $2.95 of traffic at ProxyField's 1-9 GB rate.
Scraping many pages with Cheerio? ProxyField residential proxies: 412M+ residential IPs in 195+ countries, a new IP on every request or sticky sessions up to 36 h, from $1.95/GB with no subscription; start with the Easy Trial (1 GB). Traffic never expires, and all purchases are final.
Cheerio Web Scraping Tutorial FAQ
How do I scrape a page with Cheerio in Node.js, and why does it sometimes give me empty results?
ProxyField's tutorial above scrapes all 250 countries of a sandbox page in 3 steps: fetch the URL and check res.ok, pass the HTML to cheerio.load(), and map over $("div.country") reading .text() and .attr(). Empty results mean the selector does not match the HTML the server sent, the content is added by JavaScript that Cheerio does not run, or you received an error, consent or block page. Print res.status, the first 1,000 characters of the HTML and $(selector).length to see which.
Why does Cheerio return an empty string or undefined?
Because your selector matched no elements, and Cheerio does not throw when that happens: .text() returns "", .attr() returns undefined and .length is 0. Check whether the value appears in the HTML you downloaded (View Source, not the DevTools Elements panel); if it does not, the page builds it with JavaScript.
How do I scrape a website with Cheerio?
Download the page with fetch, load the HTML with cheerio.load(html), and select values with $("selector").text() or .attr("href"). Map over repeated elements to build objects, follow the Next link for more pages, and save the result as JSON or CSV.
What is Cheerio?
Cheerio is an open-source Node.js library that parses HTML and XML and lets you query the result with a jQuery-like API. Scrapers use it to pull text, links and attributes out of downloaded pages without starting a browser.
Which Node.js version does Cheerio need?
The Cheerio 1.2.0 package on npm declares Node.js >=20.18.1, and the Cheerio docs say it runs on Node.js 22.19 or later. Node.js 22.19+ meets both and also ships the built-in fetch used on this page.
How can I handle pagination and dynamic content during Node.js web scraping?
For pagination, follow the Next link or loop the page parameter with fetch and Cheerio, and keep a set of visited URLs. For dynamic content, call the JSON endpoint the page uses, or render the page in Puppeteer or Playwright and parse page.content() with Cheerio.
How do I scrape a web page with jQuery?
In a browser, you can run jQuery selectors in the DevTools console on a page that loads jQuery, which is handy for testing selectors. To scrape from a script or a server, use Cheerio: it accepts the same selectors and methods ($("h2").text(), .attr(), .find()) on HTML you downloaded.
Is regex faster than an HTML parser for data extraction?
Sometimes, for one simple pattern in a large file, but the speed rarely matters next to network time. Regex breaks when attributes change order, quotes or line breaks, so a parser such as Cheerio is the safer default; regex is fine for cleaning a value you have already extracted.
What is an HTML parser?
An HTML parser reads HTML text and turns it into a tree of elements that code can search and change. Cheerio, Beautiful Soup and lxml are HTML parsers; browsers have one built in, and run scripts and render the page on top of it.
What is web scraping with Cheerio?
It is scraping in which you download pages yourself, usually with fetch, and use Cheerio only to parse them and pick out the data. Because no browser runs, it is fast and light on traffic per page, and it works on any site whose data is in the HTML the server sends.
Show 2 more questionsShow fewer questions
What is a CSS selector?
A CSS selector is a pattern that matches elements in an HTML document by tag, class, id, attribute or position, such as div.country h3 or a[href^="https"]. Cheerio, Beautiful Soup and browsers all accept them, so a selector you test in DevTools works in your scraper.
How do I test CSS selectors?
Open the page in your browser, open DevTools and run document.querySelectorAll("your selector") in the console, or search the Elements panel with Ctrl+F (Cmd+F on a Mac), which accepts selectors. Then check the same selector against the HTML your script downloads with $(selector).length, since scripts can change the page after load.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.