Website Scraper

JavaScript Web Scraping Tutorial (2026): Node.js + Cheerio, Step by Step

By · Updated

There's a nice symmetry to scraping with JavaScript: the language that builds most modern web pages is also a fine language for taking them apart. And it's gotten simpler — recent Node.js has fetch built in, so the classic "install axios and node-fetch first" step is gone. This tutorial uses built-in fetch to download and Cheerio to parse, and that's the whole stack for a static site. I ran every snippet against the live site on Node 22; the output you see is what I got.

Our target is books.toscrape.com, a sandbox made for scraping practice: 1,000 fictional books over 50 pages, no terms-of-service concerns, no bot detection. If you'd rather not write code at all, an AI website scraper does the same job from a pasted URL — compared honestly at the end.

What do I need to scrape a website with Node.js?

Node.js 18 or newer (I'm on 22) and one package. Node 18+ ships a global fetch, so downloading pages needs nothing installed. For parsing, add Cheerio — a fast, jQuery-flavored HTML parser that runs on the server:

node --version        # confirm 18+
npm install cheerio

That's the toolchain. Cheerio gives you $("selector") with the CSS selectors you already know from the browser, minus the browser's weight. No API key, no driver, nothing else for static pages.

How do I download a page in Node?

Because fetch is built in, the fetch step is a few honest lines — request the page, check the status, read the body as text:

const res = await fetch("https://books.toscrape.com/", {
  headers: { "User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0)" },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
console.log(`${html.length} characters`);

The User-Agent header matters more than it looks: many sites reject the default Node agent, and a browser-like string clears the easy cases. Checking res.ok matters too — fetch doesn't throw on a 404 or 403, it just hands you a response with ok: false, so an unchecked scraper happily parses an error page.

How do I parse HTML with Cheerio?

Load the HTML into Cheerio and you get a $ that behaves like jQuery. First, inspect the page to learn its shape: on books.toscrape.com each book is an <article class="product_pod">, with the title in an <h3><a title="...">, the price in .price_color, and stock in .instock.availability. That translates straight into selectors:

import * as cheerio from "cheerio";

const $ = cheerio.load(html);
const books = [];

$("article.product_pod").each((_, el) => {
  books.push({
    title: $(el).find("h3 a").attr("title"),
    price: $(el).find(".price_color").text(),
    availability: $(el).find(".instock.availability").text().trim(),
  });
});

console.log(`Found ${books.length} books`);
console.log(books.slice(0, 3));

That found the whole first page and printed the first three as objects:

Found 20 books
[
  { title: 'A Light in the Attic', price: '£51.77', availability: 'In stock' },
  { title: 'Tipping the Velvet', price: '£53.74', availability: 'In stock' },
  { title: 'Soumission', price: '£50.10', availability: 'In stock' }
]

Two details earn their keep. Reading the title from the title attribute (.attr("title")) instead of the link text avoids the ellipsis-truncated display name. And .text().trim() cleans the whitespace the markup pads around the stock label. Small habits, cleaner data.

How do I scrape multiple pages?

Wrap fetch-and-parse in an async function, then loop the pages. This site numbers them predictably — catalogue/page-1.html, page-2.html — so increment until a page comes back empty:

import * as cheerio from "cheerio";

async function scrapePage(url) {
  const res = await fetch(url, {
    headers: { "User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0)" },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  const $ = cheerio.load(await res.text());

  const rows = [];
  $("article.product_pod").each((_, el) => {
    rows.push({
      title: $(el).find("h3 a").attr("title"),
      price: $(el).find(".price_color").text(),
      availability: $(el).find(".instock.availability").text().trim(),
    });
  });
  return rows;
}

const all = [];
for (let page = 1; page <= 2; page++) {
  const rows = await scrapePage(`https://books.toscrape.com/catalogue/page-${page}.html`);
  if (rows.length === 0) break;                     // past the last page
  console.log(`page ${page}: ${rows.length} books`);
  all.push(...rows);
  await new Promise((r) => setTimeout(r, 1000));     // be polite
}
console.log(`TOTAL: ${all.length} books`);

I stopped at two pages to keep it light. The output:

page 1: 20 books
page 2: 20 books
TOTAL: 40 books

That await new Promise((r) => setTimeout(r, 1000)) is the async version of "slow down." JavaScript makes it dangerously easy to fire every request at once with Promise.all, which on a real site reads as an attack. A deliberate pause between pages keeps you a guest rather than a load test.

How do I export the data to CSV?

For a flat table, you don't need a CSV library — build the rows and write the file with the built-in fs:

import { writeFileSync } from "node:fs";

const header = "title,price,availability\n";
const body = all
  .map((r) => `"${r.title}",${r.price},"${r.availability}"`)
  .join("\n");
writeFileSync("books.csv", header + body);

The result opens straight in Excel or Sheets:

title,price,availability
"A Light in the Attic",£51.77,"In stock"
"Tipping the Velvet",£53.74,"In stock"
"Soumission",£50.10,"In stock"

Quoting the title and availability fields protects any values that contain commas. If your data has quotes or newlines inside fields, graduate to a package like csv-stringify that escapes every edge case — but for clean tabular data, this is enough.

When do I need Puppeteer instead of Cheerio?

Here's the irony worth internalizing: a JavaScript scraper still can't run a page's own JavaScript. Cheerio parses the HTML your fetch received and nothing more — it never executes scripts, never waits for an API call, never scrolls. So on a site that renders its content in the browser (a React storefront, an infinite feed), fetch + Cheerio come back empty even though your browser shows a full page.

That's when you switch to Puppeteer or Playwright, which drive a real headless Chrome: they run the page's scripts, wait for content, and let you click and scroll. The tradeoff is weight — you're launching a browser per page instead of parsing a string — so use them only when the data genuinely isn't in the initial HTML. Check by viewing source: if your values aren't there, it's a browser job.

Do you actually need to write JavaScript for this?

For a static site, fetch + Cheerio is clean and quick. But scraping rarely stays a one-file script. Selectors break when the site's markup shifts. JavaScript-rendered pages drag in Puppeteer and the infrastructure to run headless browsers. And "check it every morning and tell me what changed" means building scheduling, storage, and diffing yourself — which, having built exactly that, I can tell you is the hard 80%.

An AI scraper skips that. Website Scraper renders the page (so client-side JavaScript is no obstacle), extracts by understanding the content rather than a product_pod selector you maintain, and turns any scrape into a monitored alert with no servers on your side. If you're writing Node because it's your stack, this tutorial is the right road. If you just want the data, paste the URL into the no-code scraper and move on. The how to scrape data from a website guide covers the general workflow, and the Python and PHP tutorials show the same job in other languages.

FAQ

Can you scrape a website with JavaScript?
Yes, with Node.js. Modern Node has fetch built in, so you download a page with no library at all, then parse the HTML with Cheerio — a server-side version of jQuery. That combination scrapes any static, server-rendered site. For pages that build their content with client-side JavaScript, you step up to a headless browser like Puppeteer or Playwright.
Do I need a library to scrape with Node.js?
For fetching, no — fetch is built into Node 18 and later. For parsing, you'll want Cheerio (npm install cheerio); it turns HTML into a tree you query with familiar CSS selectors. You can technically match with regular expressions, but don't: HTML isn't regular, and a selector library is both easier and far more reliable.
What is the difference between Cheerio and Puppeteer for scraping?
Cheerio parses HTML you already have — it's fast and light but never executes JavaScript, so it only sees the server's initial HTML. Puppeteer (and Playwright) drive a real Chrome browser, so they render client-side JavaScript, click, scroll, and wait, at the cost of far more resources. Use Cheerio for static pages and reach for Puppeteer only when the data isn't in the initial HTML.
Why does my Node scraper return empty results on a page with data?
The page almost certainly renders its content with client-side JavaScript, and Cheerio only sees the HTML the server sent — not what the browser builds afterward. Check the page's view-source: if your data isn't there, fetch and Cheerio never will find it. That's the signal to switch to Puppeteer or Playwright, which run the page's scripts.
Is Node.js good for web scraping?
Very. JavaScript's async model makes concurrent requests natural, Cheerio is pleasant for static pages, and Puppeteer/Playwright are first-class for JavaScript-heavy ones, all in the same language your front end uses. The main caution is politeness: async makes it trivially easy to fire hundreds of requests at once, so rate-limit yourself deliberately.

Keep reading