JavaScript Web Scraping Tutorial (2026): Node.js + Cheerio, Step by Step
By Ashesh DhakalUpdated 6 min read
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 change alert with no servers on your side. To stay in Node and drop only the headless-browser problem, the web scraper API returns the same rows as JSON from a single POST. 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.