Tutorial9 min read

Cheerio Web Scraping Tutorial

Short answer

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
  1. What Cheerio does, and what it doesn't
  2. Install and the first scraper
  3. Why Cheerio returns empty results, and how to find the cause
  4. Select elements and read values
  5. Extract a whole object with extract()
  6. Scrape several pages
  7. Cheerio vs jsdom vs a headless browser
  8. Add a proxy to the fetch that feeds Cheerio
  9. 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 doesCheerio does not
Parse HTML and XML, including messy markupRun the page's JavaScript or render CSS
Select with CSS selectors in jQuery syntaxClick, scroll or fill forms
Read text, attributes and HTML; walk parents and siblingsWait 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

Shell
npm install cheerio

Cheerio 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:

JavaScript
// 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:

#CheckCodeWhat it tells you
1Did the request succeed?res.ok, res.status, res.urlA 403, 429 or 5xx, or a redirect to a login or consent page, gives HTML without your data
2What did the server send?console.log(html.slice(0, 1000))A challenge page, a cookie-consent wall or an empty <div id="root"></div> shows up here
3Does the selector match?$("div.country").length0 means the selector or the HTML is wrong; test each part of the selector on its own
4Is 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)
5Where does the data come from?DevTools, Network tab, Fetch/XHRA 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:

JavaScript
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:

JavaScript
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 Spotlight

Both scripts printed these values on 2026-10-27. The three ways out of a JavaScript-built page, from lightest to heaviest:

  1. Call the JSON request

    The page makes, as above. See scraping hidden APIs.

  2. 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().

  3. Render the page

    With Puppeteer or Playwright, or emulate the DOM with jsdom, then pass await page.content() to cheerio.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).

Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.

Select elements and read values

JavaScript
$("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 tree

Points 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":

JavaScript
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:

JavaScript
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 teams

The 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:

JavaScript
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

FeatureCheeriojsdomPuppeteer or Playwright
What it isHTML parser with a jQuery-like APIA DOM implementation in Node.jsA real browser you control
Runs page scriptsNoOptionally, with limitsYes
Speed and memoryFastest, lightestSlower, heavierSlowest, heaviest
API$(), .find(), .text()Standard DOM: querySelector, textContentBrowser API plus page actions
Use it forStatic pages, high volumeCode that expects a browser DOMPages 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:

JavaScript
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.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.