Back to all posts

How to Scrape Yelp with Node.js (2026)

Yelp has 186 million reviews across 5.7 million businesses, covering business names, phone numbers, addresses, star ratings, and full review text across every location and category.

Pulling it programmatically is harder than it looks. A plain axios GET returns a 403 before it reaches the HTML. Puppeteer (no stealth) gets flagged within a handful of requests. And the CSS selectors that worked in 2022 no longer exist. Yelp’s React build regenerates obfuscated class names on every deployment.

This guide covers three approaches that work today:

ApproachDataSetupMaintenance
Yelp Fusion APIBusiness info, short review excerptsDeveloper accountNone
Embedded JSON extractionFull search + business + reviewsBypass tool + parsing codeLow
HasData Yelp APIFull search + business + reviewsAPI keyNone

Start with the Fusion API if you need basic business info at low volume. Move to the embedded JSON approach or HasData if you need full review text, more than 1,000 results per query, or data the official API doesn’t expose.

How Yelp Organizes Its Data

Yelp’s search URL uses two main query parameters: find_desc for the search term and find_loc for the location.

https://www.yelp.com/search?find_desc=restaurants&find_loc=San+Francisco+CA

Pagination works with a start offset. The first page has no start parameter (or start=0), the second is start=10, the third is start=20. Each page returns 10 results. To page through all results, increment start by 10 until there are no more pages.

https://www.yelp.com/search?find_desc=restaurants&find_loc=San+Francisco+CA&start=10

Business detail pages use a placeAlias slug in the URL path, like /biz/nara-restaurant-and-sake-bar-san-francisco. Search results return both the placeId and placeAlias for every result, so you don’t need to construct detail page URLs by hand.

Search result cards show the business name, category, price tier, star rating, review count, street address, and a review snippet. Business detail pages add full hours, amenities (takeout, delivery, outdoor seating, parking), neighborhood, phone number, website link, and health inspection scores where available. Reviews are paginated on business pages, with each review showing full text, author name, location, photo count, and friend count.

If you only need lead data (names, phones, addresses), a search scraper covers everything. If you need reviews for sentiment analysis or full business attributes for enrichment, you also need the business detail and reviews pages.

Where Yelp stores its data

Yelp renders search pages server-side using Hypernova, a React SSR framework. The rendered HTML includes structured JSON embedded in <script> tags. It’s the same data structure Yelp’s internal API returns, serialized into the page before it leaves the server. For business detail pages, a similar embedded block stores the full page state.

These script blocks matter because they’re decoupled from the presentation layer. Yelp’s CSS class names are hashed by the build system and regenerate on every deployment. The embedded JSON schema reflects Yelp’s internal data contract and changes far less often. A scraper targeting embedded JSON breaks less frequently than one targeting CSS classes.

Why Node.js Scrapers Fail on Yelp

Yelp’s defenses operate at the network, browser, and selector level. Each breaks a different type of scraper.

axios and fetch return 403 before touching the HTML

Yelp uses DataDome for bot protection. DataDome fingerprints the TLS handshake at the connection level, before HTTP headers are parsed. Node.js’s built-in TLS stack produces a fingerprint that doesn’t match any real browser, so the connection fails regardless of what headers you send.

I tested this with a complete set of Chrome headers on macOS:

import axios from 'axios';

const res = await axios.get('https://www.yelp.com/search?find_desc=restaurants&find_loc=New+York', {
  headers: {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.187 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'en-US,en;q=0.9',
    'Accept-Encoding': 'gzip, deflate, br'
  }
});

The response confirms the block:

AxiosError: Request failed with status code 403
x-datadome: datadome
Server: DataDome

The User-Agent is irrelevant because DataDome blocks at the TLS handshake, not the HTTP layer. There’s no header combination that fixes a TLS fingerprint mismatch.

Puppeteer (no stealth) gets detected too

A headless Chrome browser sends a real browser TLS fingerprint, so it passes the initial connection check. DataDome then runs behavioral analysis. It checks navigator.webdriver, timing patterns, WebGL fingerprints, and canvas rendering consistency. Headless Chrome without stealth patches exposes navigator.webdriver === true and several other signals that flag the session as automated.

A basic Puppeteer scraper typically gets through a few pages before hitting a challenge page or block.

CSS selectors break on every deploy

The original version of this article used these selectors to parse Yelp search results:

// 2022 version, all broken now
$('.pagination__09f24__VRjN4 .css-chan6m').text()
$('a.css-1m051bw').filter(...)
$('p[class=" css-1p9ibgf"]').filter(...)
$('span[class=" css-1fdy0l5"]').filter(...)

Every one of those class names is gone. Yelp’s React build generates hash-suffixed class names, and the hash regenerates on every frontend build. Any scraper targeting those classes works the day you write it, then breaks silently the next time Yelp ships code. That’s not a fixable bug. It’s a structural property of the site.

Yelp Fusion API

The Fusion API is Yelp’s official developer interface. Sign up at yelp.com/developers, create an app to get an API key, then call the REST API with that key in the Authorization header.

The business search endpoint is /v3/businesses/search:

import axios from 'axios';

const FUSION_KEY = 'YOUR_YELP_FUSION_API_KEY';

async function searchFusion(term, location, offset = 0) {
  const res = await axios.get('https://api.yelp.com/v3/businesses/search', {
    headers: { Authorization: `Bearer ${FUSION_KEY}` },
    params: { term, location, limit: 50, offset }
  });
  return res.data;
}

// Fetch first two pages (100 results total)
const page1 = await searchFusion('sushi', 'San Francisco, CA', 0);
const page2 = await searchFusion('sushi', 'San Francisco, CA', 50);

const businesses = [...page1.businesses, ...page2.businesses];
console.log(businesses.map(b => ({
  name: b.name,
  rating: b.rating,
  reviewCount: b.review_count,
  address: b.location.display_address.join(', '),
  phone: b.display_phone
})));

Each business object in the response looks like this:

[
  {
    "name": "Akikos",
    "rating": 4,
    "reviewCount": 2074,
    "address": "430 Folsom St, San Francisco, CA 94105",
    "phone": "(415) 267-9302"
  }
]

The API returns clean, stable JSON with no proxy, no browser automation, and no selector maintenance.

Fusion API limits

LimitValue
Results per request50 max
Max results per query1,000
Free requests per day5,000
Review textShort excerpts, no full text
Review historyNot available

The Fusion API has a reviews endpoint at /v3/businesses/{id}/reviews. It returns up to three reviews per business, each truncated to about three sentences. There’s no way to paginate past those three, and there’s no option to sort by date or rating. Three excerpts per business covers a quick sentiment check. For systematic review analysis it’s not enough.

Outside of that reviews limit, the Fusion API handles most other use cases well. Business directories, contact enrichment, and rating comparisons all stay within it. The limits that push toward scraping are specific: no full review text, 1,000 results max per location-term pair, no full business hours or amenities.

Extracting Yelp’s Embedded JSON

The embedded JSON approach targets the Hypernova script blocks rather than the HTML class structure. The schema is more stable than CSS class names because it reflects Yelp’s internal API contract rather than the build output. It also gives access to data the Fusion API doesn’t expose, including full review text.

Finding the script tag

In the raw page source of any Yelp search page, search for data-hypernova-key. The tags follow this structure:

<script type="application/json" data-hypernova-key="yelpfrontend__SearchResultsPage">
  <!--{"searchPageProps":{"searchResultsProps":{"businesses":[...]}}}-->
</script>

The JSON is wrapped in an HTML comment (<!-- -->) to prevent the browser from treating it as executable code. For business detail pages, look for a script tag with data-hypernova-key containing BusinessPage. Yelp also stores GraphQL response data in an Apollo cache block, a script tag that sets window.__APOLLO_STATE__ and contains structured business data, hours, and review excerpts in a normalized format. On business pages both formats may be present, and the Hypernova block is typically easier to parse since Apollo cache uses ID-based normalization with many cross-referenced keys.

Extracting with Node.js

A regex match finds the script tag content, and JSON.parse turns it into an object:

function extractHypernovaData(html) {
  const match = html.match(
    /<script[^>]+data-hypernova-key[^>]*><!--([\s\S]*?)--><\/script>/
  );
  if (!match) return null;

  try {
    return JSON.parse(match[1]);
  } catch {
    return null;
  }
}

function extractBusinesses(html) {
  const data = extractHypernovaData(html);
  return data?.searchPageProps?.searchResultsProps?.businesses ?? [];
}

For a paginating scraper:

import axios from 'axios';

async function fetchYelpPage(keyword, location, start = 0) {
  const url = `https://www.yelp.com/search?find_desc=${encodeURIComponent(keyword)}&find_loc=${encodeURIComponent(location)}&start=${start}`;

  // Standard axios returns 403, replace with a bypass-capable client
  const html = await fetchWithBypass(url);
  return extractBusinesses(html);
}

async function scrapeAllPages(keyword, location) {
  const results = [];
  let start = 0;

  while (true) {
    const businesses = await fetchYelpPage(keyword, location, start);
    if (!businesses.length) break;
    results.push(...businesses);
    start += 10;
  }

  return results;
}

The Hypernova JSON is cleaner and more predictable than Yelp’s HTML. Getting the raw HTML is the harder part. Standard axios returns a DataDome 403, and basic Puppeteer gets flagged. Fetching Yelp’s HTML requires either a TLS-spoofing HTTP client (Python has curl_cffi, Node.js has no direct equivalent at the same maturity level) or a service that handles proxy rotation and ban detection, like HasData Web Scraping API. If you’d rather not manage the bypass layer, the dedicated Yelp API below handles it for you.

HasData Yelp API

The HasData Yelp Search API and Yelp Place API return structured data through a single GET request. No HTML parsing, no proxy setup, no selector maintenance. The response format stays consistent regardless of what Yelp deploys.

Search results

import axios from 'axios';

const API_KEY = 'YOUR_HASDATA_API_KEY';

async function searchYelp(keyword, location, start = 0) {
  const res = await axios.get('https://api.hasdata.com/scrape/yelp/search', {
    headers: { 'x-api-key': API_KEY },
    params: { keyword, location, start }
  });
  return res.data;
}

const data = await searchYelp('sushi', 'San Francisco CA');
console.log(data.organicResults[0]);

Each entry in organicResults looks like this:

{
  "position": 1,
  "placeId": "cL0q9S4bqwpbAN9ZKh-Zeg",
  "placeAlias": "nara-restaurant-and-sake-bar-san-francisco",
  "title": "Nara Restaurant & Sake Bar",
  "streetAddress": "518 Haight St, San Francisco",
  "price": "$$$",
  "categories": [{"title": "Japanese"}, {"title": "Sushi Bars"}],
  "rating": 4.6,
  "reviews": 2264,
  "snippet": "Great fresh sushi. I love eating here. The o-toro is one of my favorites."
}

Paging through all results uses the pagination object:

async function scrapeAllPages(keyword, location) {
  const allResults = [];
  let start = 0;

  while (true) {
    const data = await searchYelp(keyword, location, start);
    allResults.push(...data.organicResults);

    if (!data.pagination.nextPageUrl) break;
    start += data.pagination.perPage;
  }

  return allResults;
}

For full business profiles and reviews, two more endpoints extend the same API key and response pattern.

Business details and reviews

The placeId from any search result feeds directly into the place and reviews endpoints:

async function getBusinessDetails(placeId) {
  const res = await axios.get('https://api.hasdata.com/scrape/yelp/place', {
    headers: { 'x-api-key': API_KEY },
    params: { placeId }
  });
  return res.data.placeResult;
}

async function getReviews(placeId) {
  const res = await axios.get('https://api.hasdata.com/scrape/yelp/reviews', {
    headers: { 'x-api-key': API_KEY },
    params: { placeId }
  });
  return res.data;
}

const data = await searchYelp('sushi', 'San Francisco CA');
const top = data.organicResults[0];

const details = await getBusinessDetails(top.placeId);
const reviewData = await getReviews(top.placeId);

console.log(details.address, details.phone);
console.log(reviewData.reviews[0].comment.text);

The place response includes full business hours, amenities (takeout, delivery, outdoor seating, parking), neighborhood, and specialties. Reviews return complete text with no excerpt truncation, along with author name, star rating, date, and vote counts.

For businesses with large review counts, paginate with the start parameter. The response includes pagination.hasNextPage and pagination.nextPageOffset. When hasNextPage is true, pass nextPageOffset as start on the next call:

async function getAllReviews(placeId) {
  const allReviews = [];
  let start = 0;

  while (true) {
    const res = await axios.get('https://api.hasdata.com/scrape/yelp/reviews', {
      headers: { 'x-api-key': API_KEY },
      params: { placeId, start }
    });

    allReviews.push(...res.data.reviews);

    if (!res.data.pagination.hasNextPage) break;
    start = res.data.pagination.nextPageOffset;
  }

  return allReviews;
}

I use it when I need Yelp data reliably without managing proxy pools or updating selectors after every Yelp deploy.

Saving Results to a File

Scraping returns an array of business objects. Writing it to JSON or CSV takes a few extra lines. Here’s a complete script that searches for businesses and saves results to a file:

import axios from 'axios';
import fs from 'fs';

const API_KEY = 'YOUR_HASDATA_API_KEY';

async function scrapeYelpToJson(keyword, location, outputFile) {
  const allResults = [];
  let start = 0;

  console.log(`Scraping "${keyword}" in ${location}...`);

  while (true) {
    const res = await axios.get('https://api.hasdata.com/scrape/yelp/search', {
      headers: { 'x-api-key': API_KEY },
      params: { keyword, location, start }
    });

    const { organicResults, pagination } = res.data;
    allResults.push(...organicResults);

    console.log(`Page ${pagination.currentPage}/${pagination.totalPages}, ${allResults.length} results so far`);

    if (!pagination.nextPageUrl) break;
    start += pagination.perPage;
  }

  fs.writeFileSync(outputFile, JSON.stringify(allResults, null, 2));
  console.log(`Saved ${allResults.length} results to ${outputFile}`);
  return allResults;
}

await scrapeYelpToJson('sushi restaurants', 'San Francisco CA', 'yelp-results.json');

For CSV output, csv-writer handles column mapping and field escaping:

import { createObjectCsvWriter } from 'csv-writer';

const csvWriter = createObjectCsvWriter({
  path: 'yelp-results.csv',
  header: [
    { id: 'title', title: 'Name' },
    { id: 'streetAddress', title: 'Address' },
    { id: 'rating', title: 'Rating' },
    { id: 'reviews', title: 'Reviews' },
    { id: 'price', title: 'Price' },
    { id: 'placeAlias', title: 'Yelp URL Slug' }
  ]
});

await csvWriter.writeRecords(allResults);

Install it with npm install csv-writer. The placeAlias field from each result gives you the full business URL as https://www.yelp.com/biz/${placeAlias}, which you can use later to fetch business details or reviews for any record.

Choosing an approach

Start with the Fusion API. It covers business search, contact details, and rating data without any scraping infrastructure, and Yelp explicitly supports it.

The embedded JSON approach gets you full review text and results beyond the 1,000-per-query cap, but it requires a bypass-capable client to fetch Yelp’s raw HTML. The Hypernova schema is stable. Getting the page reliably is the harder part.

The HasData Yelp API covers the same ground as embedded JSON. One API key handles proxy rotation, TLS fingerprinting, and ban detection. Search, business details, and full reviews each take a single GET request.

Roman Milyushkevich
Roman Milyushkevich
Roman Milyushkevich is the Co-founder and CTO at HasData, a web scraping API handling billions of requests. He designs the distributed systems, proxy infrastructure, and APIs behind large-scale, reliable data extraction. Roman writes on API design, browser automation, and building scraping pipelines that hold up in production.
Articles

Might Be Interesting