Scrape embedded JSON: read JSON-LD and __NEXT_DATA__ instead of HTML
How to scrape embedded JSON from JSON-LD, __NEXT_DATA__ and window state scripts: find it in the raw HTML, extract fields and lists, skip the browser.
- Published
- Reading time
- 9 min read
- By
- ExtractHub team
- embedded JSON
- JSON-LD
- Next.js
- web scraping
- tutorial
On this page
Before you write a single CSS selector for a product page, open its source and search for <script. A lot of sites already ship the data you want as JSON: a schema.org JSON-LD block for search engines, the __NEXT_DATA__ script that hydrates a Next.js page, or a window.__INITIAL_STATE__ = {...} assignment from a single-page app. When you scrape embedded JSON instead of the rendered markup, you read typed values under keys the site’s own code depends on.
This post shows how to find embedded JSON in a page, how ExtractHub reads it with jsonContent, how to turn a JSON array into a clean list with jsonPath and [*], and where the approach stops working.
Why scrape embedded JSON instead of HTML
A CSS selector depends on how a page looks. Embedded JSON depends on what the page means. That difference shows up in four ways:
- Stability. Generated class names such as
.sc-1x2y3zchange with every build. The keys of a JSON-LDProduct(name,offers.price,sku) are a public vocabulary, and the props in__NEXT_DATA__are what the site’s own components read. - Typed values. A price in JSON is often already a number, a date is often ISO 8601, a URL is often absolute. In the markup the same values are display text (“$1,299.00”, “3 days ago”) that you have to clean up.
- Completeness. The JSON often holds fields the template never shows: ids, SKUs, stock flags, every image size, the full description.
- Cost. The JSON is in the server’s HTML response, so a plain HTTP request is enough. No headless browser, no JavaScript rendering.
The trade-off: the JSON is an internal detail of the site too. A site can rename a prop in its state object just as it can rename a class. In practice, JSON-LD changes least (it exists for search engines), framework state changes when the site changes its data model, and class names change most often.
Step 1: find the JSON in the raw HTML
Look at the HTML the server sends, not at the DOM in your browser’s inspector: the inspector shows the page after JavaScript ran, which may contain things a plain fetch never gets. Save the raw response and search it:
curl -s "https://www.example.com/product/123" -o page.html
grep -o '<script[^>]*>' page.html | sort | uniq -c
The script tags worth checking:
| Source | Selector | Typical content |
|---|---|---|
| schema.org JSON-LD | script[type="application/ld+json"] | Product, Article, JobPosting, ItemList, BreadcrumbList |
| Next.js (Pages Router) | script#__NEXT_DATA__ | the page props under props.pageProps |
| Nuxt 3 | script#__NUXT_DATA__ | the page payload |
| Other inline JSON | script[type="application/json"][id="..."] | app config, initial data |
| Window state | script#__window__NAME (see below) | window.__INITIAL_STATE__ = {...} and similar |
Then search for a value you can see on the page, such as the product title or the price. If it appears inside one of these scripts, you have found your source.
Step 2: read single values with jsonContent
In an ExtractHub extractor, an HTML field with extract: "jsonContent" parses the matched script’s text as JSON. With jsonPaths it returns the value at that path. An illustrative example for a product page with a JSON-LD Product block (the paths fit schema.org’s vocabulary, not a particular site):
{
"sourceType": "html",
"fields": [
{ "name": "name", "selector": "script[type=\"application/ld+json\"]", "extract": "jsonContent", "jsonPaths": ["name"], "required": true },
{ "name": "sku", "selector": "script[type=\"application/ld+json\"]", "extract": "jsonContent", "jsonPaths": ["sku"] },
{ "name": "price", "selector": "script[type=\"application/ld+json\"]", "extract": "jsonContent", "jsonPaths": ["offers.price"], "transforms": ["toNumber"] },
{ "name": "currency", "selector": "script[type=\"application/ld+json\"]", "extract": "jsonContent", "jsonPaths": ["offers.priceCurrency"] }
]
}
What the engine does with these paths:
- One path returns the value with its JSON type: number, string, object, array, boolean or null.
- Several paths return an object keyed by the full path text, for example
{"offers.price": 1299, "offers.priceCurrency": "USD"}. - No
jsonPathsreturns the whole parsed JSON, handy while exploring. - Path syntax is dot notation with
[n]indexes and quoted keys for odd names:props.pageProps["product-detail"].name, or["@graph"][0].namefor JSON-LD that wraps its nodes in@graph. A leading$.is accepted. - Unparsable JSON or a missing path gives
null, never an exception.
Keep toNumber on prices even when the JSON looks numeric: some sites write "price": "1299.00" as a string.
The NEXT_DATA version
A Next.js page that uses the Pages Router ships its props in <script id="__NEXT_DATA__" type="application/json">. The same product, as an illustrative example where the site’s props happen to be called product:
{ "name": "name", "selector": "script#__NEXT_DATA__", "extract": "jsonContent", "jsonPaths": ["props.pageProps.product.title"], "required": true }
The path below props.pageProps is the site’s own data model, so find it in your saved sample rather than guessing it.
Window state without a browser
Many apps assign their state in a plain script: window.__INITIAL_STATE__ = {...}, window["__APOLLO_STATE__"] = {...}, window.__DATA__ = JSON.parse('...') or var data = {...}. That is JavaScript, not a JSON script tag, so ExtractHub exposes each assignment as a virtual script you can select: script#__window__INITIAL_STATE, script#__window__APOLLO_STATE, script#__window__data (the name without leading and trailing underscores). Nothing is executed: only literals that are valid JSON are recovered, so a JavaScript object literal with unquoted keys or functions is skipped.
{ "name": "productId", "selector": "script#__window__INITIAL_STATE", "extract": "jsonContent", "jsonPaths": ["product.id"] }
Step 3: turn a JSON array into a list
A search or category page usually keeps its results in an array: an ItemList in JSON-LD, a product grid in __NEXT_DATA__, a results array in the window state. For that, give the field one jsonPath (singular) instead of jsonPaths. The rules of ExtractHub’s JSON engine then apply to the script’s JSON:
[*]in the path walks every element of an array;subFields, each with its ownjsonPathrelative to the element, pick the properties you want;requiredon a sub-field drops elements that miss it;includePosition: trueaddsposition(1..n) as the first key of each item, numbered after incomplete items were dropped.
An illustrative example for a category page with a JSON-LD ItemList:
{
"name": "items",
"selector": "script[type=\"application/ld+json\"]",
"extract": "jsonContent",
"jsonPath": "itemListElement[*]",
"includePosition": true,
"subFields": [
{ "name": "name", "jsonPath": "name", "required": true },
{ "name": "url", "jsonPath": "url", "required": true }
]
}
The output is an array of objects:
{
"items": [
{ "position": 1, "name": "Example laptop 14", "url": "https://www.example.com/p/123" },
{ "position": 2, "name": "Example laptop 16", "url": "https://www.example.com/p/456" }
]
}
The same field shape works on __NEXT_DATA__ ("jsonPath": "props.pageProps.results[*]" in an illustrative site) and on window state. For nested lists inside each element, use subExtractor.fields instead of subFields: each element is then read by full JSON fields with their own [*] paths.
Two constraints of the list form: no isArray (the [*] makes the list) and no combineStrategy, and you cannot mix jsonPath with jsonPaths on one field.
Pages with several JSON-LD blocks
A real page often has three or four ld+json scripts: an Organization, a WebSite with a search action, a BreadcrumbList, then the block you want. The two forms treat that differently:
- A
jsonPathsfield reads the first matching script. If theProductblock is not first, make the selector more specific or check the order in your sample. - A
jsonPathfield tries the matched scripts in document order and uses the first one whose JSON parses and gives a non-empty value at the path. A block withoutitemListElementis skipped. ABreadcrumbListalso hasitemListElement, so if it comes before yourItemList, select the list block more precisely.
When a value can sit at two different paths (an A/B test, two page variants), give the field several jsonPaths and the coalesce transform: the first path that has a value wins. When it can sit in two different scripts, use a selector array with combineStrategy: "first": if the first script exists but its paths give nothing, the next script is tried.
{ "name": "title", "selector": "script#__NEXT_DATA__", "extract": "jsonContent",
"jsonPaths": ["props.pageProps.product.title", "props.pageProps.item.name"], "transforms": ["coalesce"] }
Embedded JSON and the price of each call
An ExtractHub extractor’s request options decide its price tier. Rendering JavaScript (or capturing background requests, or browser actions) puts every call on the JavaScript tier. A plain fetch through the proxy is the middle tier, and a direct fetch without the proxy (useProxy: false) is the cheapest. The current prices are in the pricing section.
Embedded JSON keeps a page that “needs JavaScript” on a plain-fetch tier: the browser would only have run the script that turns the JSON into markup, and you read the JSON directly. A direct request also skips the realtime capacity queue, and a failed direct request is not charged. Use it for sites that answer a plain request; when a site blocks direct requests, the proxy tier still reads the same JSON.
Check the raw HTML first, though. If the server sends an empty shell and the data arrives later from an API call, there is no embedded JSON to read: call that JSON API directly with sourceType: "json", or capture the background response with JavaScript rendering.
Validate on a saved sample, then release
Test the fields on the page you saved, not on the live site. With the ExtractHub extractor builder skill the bundled script runs the real extraction engine on the file, without a fetch and without credit:
node scripts/validate.mjs example-category.json --html page.html --url "https://www.example.com/category/laptops"
It returns each field’s value and warns about fields that matched nothing on the sample. With the MCP server connected, an agent such as Claude Code runs the same check through validate_extractor_config, then creates, tests and releases the extractor; the Claude Code tutorial walks through that loop.
Two points matter for embedded JSON when you release:
- Output types come from the sample. A config does not fix the type of a
jsonContentvalue, so the release takes it from the sample result (string, number, nested object or array). A value that is null on the sample staysunknown. Release with a sample where every field has a value. - Keep
requiredshort. Mark the fields that prove the page parsed (a name, a URL) as required. An empty required field fails the request, and health checks judge only required fields, so an optional field that is missing on some products never flags the version.
Then call it like any other extractor, with a pinned version:
curl -X POST "https://api.extracthub.io/api/extractor/extract" \
-H "X-API-Key: $EXTRACTHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"extractor_id": "@you/example-category@1",
"target": { "url": "https://www.example.com/category/desktops" }
}'
When embedded JSON is the wrong source
- The JSON is a summary. JSON-LD sometimes carries only a subset (one offer of many, the first page of reviews). Compare it with what the page shows before you rely on it.
- The values disagree with the page. A stale
pricein JSON-LD and a current price in the markup happen. If the visible value is the one you need, use a CSS selector for that field, ideally on an attribute such asitemprop="price"withcontent. - The state is a JavaScript object literal. Unquoted keys or functions are not JSON; the window-state reader skips them. Fall back to the markup or to the site’s JSON API.
- The data is not in the first response. Then nothing in the HTML can help; see the JSON API and background capture options above.
Mixing sources in one extractor is fine: JSON-LD for the name, SKU and price, a CSS selector for a badge that only exists in the markup.
Next steps
- Browse the public extractor catalog to see released extractors and their output schemas.
- Read the endpoint details (realtime, jobs, batch) in the API reference.
- Install the extractor builder skill and let your agent check a page for embedded JSON before it writes any selector.
FAQ
What is embedded JSON in a web page?
It is data the site ships as JSON inside a script tag of the HTML: schema.org JSON-LD blocks, the __NEXT_DATA__ script of Next.js pages, or state assigned to a window variable such as window.__INITIAL_STATE__. It is in the raw HTML, so a plain HTTP request gets it without a browser.
Is scraping JSON-LD better than using CSS selectors?
Often, yes. JSON-LD and framework state keep stable keys and typed values (numbers, ISO dates, absolute URLs), while class names and markup change with every redesign. When the data you need is in the JSON, read it there and keep CSS selectors for what is only in the markup.
Do I need a headless browser to scrape __NEXT_DATA__?
No. The __NEXT_DATA__ script is part of the server-rendered HTML, so a plain fetch has it. In ExtractHub an extractor that reads it with jsonContent stays on a plain-fetch tier instead of the JavaScript rendering tier.
How do I extract a list from a JSON array inside a script tag?
Give the jsonContent field one jsonPath that ends in [*] at the array, plus subFields with their own relative jsonPath. Each array element becomes one object with the properties you chose; required sub-fields drop incomplete items and includePosition numbers them.
How do I scrape JSON-LD when a page has several blocks?
A field with jsonPaths reads the first matching script, so target the block you want with a more specific selector or check the order. A field with a single jsonPath uses the first matched script whose JSON parses and gives a non-empty value at that path.
Related
- Versioned web scrapers: why your scrapers need semantic versioning: A versioned web scraper gives callers a stable contract: semver bumps computed from the output schema, version pins like @1, Deprecation and Sunset headers.
- How to build a web scraper with Claude Code and ExtractHub: Build a web scraper with Claude Code: the ExtractHub skill inspects the page, validates the extractor without credit, releases a version you call by API.
- Agent skill: install the extractor builder skill for Claude Code
- MCP server: connect Claude Code, Cursor or any MCP client
- API reference: realtime extract, async jobs and the Batch API
- Web scraper catalog: public extractors you can call today