The HTML node in n8n extracts content from an HTML string using CSS selectors — the same selectors you would use in document.querySelector. It does not fetch anything. You pair it with an HTTP Request node that gets the page first, and the most common reason it returns nothing is that the page renders its content with JavaScript.
The node used to be called HTML Extract, which is why searching for it turns up documentation under both names. It does one job well: turn markup into structured fields. Understanding what it cannot do saves more time than understanding what it can.
Table of contents
- The two-node pattern
- Writing selectors that survive
- When the page needs a browser
- Handling missing elements without breaking the workflow
- Scraping responsibly, and not getting blocked
- Where the workflow and its history live
- How this fits the rest of the stack
- FAQ
The two-node pattern
HTML extraction is always at least two nodes: fetch, then extract.
- HTTP Request — method GET, the target URL, response format set to String (not JSON, or n8n will try to parse markup as JSON and fail).
- HTML — operation Extract HTML Content, source data JSON, with the property name matching the HTTP node’s output field, usually
data.
Then add one extraction value per field you want:
Key: title
CSS Selector: h1.product-title
Return Value: Text
Key: price
CSS Selector: span[data-price]
Return Value: Attribute
Attribute: data-price
Key: images
CSS Selector: .gallery img
Return Value: Attribute
Attribute: src
Return Array: true
Return Value options matter more than the selector. Text gives the visible text, HTML gives the inner markup, Attribute gives a named attribute, and Value reads form input values. Picking Text when you wanted an href is the most common early mistake.
Return Array is the difference between the first match and all of them. Without it, a selector matching twelve products returns one.
Writing selectors that survive
The node uses standard CSS selectors, so everything you know from the browser applies.
h1 element
.product-card class
#main-content id
[data-testid="price"] attribute
.card > .title direct child
.card .title any descendant
ul li:first-child first match
ul li:nth-child(2) second
a[href^="/products/"] href starting with
img[src$=".jpg"] src ending with
.card:not(.sponsored) negation
Build selectors in the browser first. Open DevTools on the target page and test in the console before wiring anything up:
// Does the selector match, and how many?
document.querySelectorAll('.product-card .title').length;
// What does it actually return?
[...document.querySelectorAll('.product-card .title')]
.map(el => el.textContent.trim());
One important caveat: DevTools shows the DOM after JavaScript has run. A selector that works in the console can still fail in n8n if the element was created client-side. Check View Page Source — the raw response — rather than the Elements panel when verifying that content exists server-side.
Prefer stable hooks. data-* attributes and semantic classes survive redesigns; generated class names like .css-1x2y3z4 change on every build and will break your workflow silently.
When the page needs a browser
This is the failure that accounts for most the HTML node returns nothing questions. The HTTP Request node fetches raw HTML. It does not run JavaScript. If the content is rendered client-side, it simply is not in what you fetched.
# Confirm before debugging selectors: is the data in the response at all?
curl -s https://example.com/products | grep -c 'product-card'
# Zero means client-side rendering. No selector will fix that.
# A count means the markup is there and your selector is wrong.
That one command separates the two problems and saves a lot of guesswork.
Options when the content is genuinely client-side:
- Find the underlying API. Open the Network tab, filter to XHR, and look for the JSON the page is fetching. Calling that directly is faster, more stable, and skips HTML parsing entirely. This is the best outcome and it is available more often than people expect.
- Look for embedded JSON. Many sites ship their data in a
<script type="application/ld+json">block or a__NEXT_DATA__script tag. Extract that with the HTML node and parse it — structured data, no scraping fragility. - Use a headless browser. A Puppeteer-based node or a rendering service. Slower and heavier, and the last resort.
- Check for a sitemap or feed. An RSS feed or a
sitemap.xmlis a supported interface, unlike scraping.
Always look for the JSON-LD block first. Product, article, and event pages very often carry complete structured data for search engines, and reading it is both easier and far more stable than parsing presentation markup.
Handling missing elements without breaking the workflow
Real pages are inconsistent. A product without a discount has no discount element, and the node returns undefined for that key.
Normalise in a Code node immediately after extraction, rather than letting nulls propagate into everything downstream:
return $input.all().map(item => {
const j = item.json;
const price = (j.price ?? '').replace(/[^0-9.]/g, '');
return {
json: {
title: (j.title ?? '').trim(),
price: price ? Number(price) : null,
url: j.url ? new URL(j.url, 'https://example.com').href : null,
inStock: (j.availability ?? '').toLowerCase().includes('in stock'),
scrapedAt: new Date().toISOString(),
},
};
});
Two things worth doing every time. Resolve relative URLs — href="/products/42" is useless downstream without the origin, and new URL(path, base) handles it correctly including edge cases you would get wrong by hand. And strip currency symbols before converting to a number, because "$1,299.00" becomes NaN otherwise.
Add an If node after normalisation to route incomplete records somewhere visible rather than letting them flow into your database as rows full of nulls.
Scraping responsibly, and not getting blocked
Worth stating plainly: check the target’s terms of service and robots.txt before building anything, and do not scrape personal data. Beyond the legal question, there is a practical one — aggressive scraping gets you blocked and the workflow stops working.
- Set a real User-Agent identifying who you are, ideally with a contact URL. Sites block anonymous scrapers far more readily than identified ones.
- Rate limit. Add a Wait node between requests. One request per second or slower is polite and rarely triggers anything.
- Cache. Do not refetch a page that has not changed. Store an ETag or Last-Modified and send conditional requests.
- Handle 429 and 503 with backoff rather than retrying immediately, which turns a temporary limit into a ban.
- Batch with Split In Batches rather than firing a hundred parallel requests at one host.
# HTTP Request node headers
User-Agent: MyCompanyBot/1.0 (+https://mycompany.com/bot)
Accept: text/html,application/xhtml+xml
Accept-Language: en-GB,en;q=0.9
Expect selectors to break. Scraping depends on someone else’s markup, which changes without notice. Build in an error branch that notifies you when the extraction returns empty, rather than discovering three weeks later that your data stopped arriving.
Where the workflow and its history live
A scraping workflow on a schedule produces two operational needs: somewhere durable to write results, and enough execution history to debug the day it silently starts returning nothing.
n8n stores execution data in its own database, and the default SQLite file is a poor place for either. It locks under concurrent writes and grows without bound until someone notices the disk.
RunxBuild runs n8n as a managed tool with its own plan, custom domains, environment variables, autoscaling, and logs, with managed Postgres available alongside it — n8n on a $6 Basic plan with a database beside it is the usual small-team shape. The database documentation covers connection limits and backups.
Set an execution retention window deliberately. Keeping everything fills the disk; keeping nothing means that when a selector breaks, you have no record of what the page returned before it did.
How this fits the rest of the stack
Pair an HTTP Request node with the HTML node, build selectors in the browser but verify against View Source rather than the Elements panel, and check whether the data exists in the raw response before debugging selectors. Look for an underlying API or a JSON-LD block first — both beat parsing markup. If you are pricing self-hosted n8n with a real database behind it, the RunxBuild hosting calculator shows the tool plan and database as separate line items.
Useful related references:
- n8n AI Agent Node: What It Does and When a Plain Chain Is Better
- n8n + Qdrant: A Vector Search Node for Real Workflows
- n8n HTTP Request Node: The Auth and Error Playbook
- Node services on RunxBuild
FAQ
Why does the n8n HTML node return nothing?
Most often the page renders its content with JavaScript, and the HTTP Request node fetches only raw HTML. Run curl against the URL and grep for your target markup — if it is absent, no selector will find it.
What is the difference between the HTML node and HTML Extract?
They are the same node. It was renamed from HTML Extract to HTML, which is why documentation and community posts refer to both names.
How do I extract multiple items instead of just the first?
Enable Return Array on the extraction value. Without it the node returns only the first element matching your selector, even when many match.
How do I get an attribute like href instead of the text?
Set Return Value to Attribute and name the attribute, for example href or src. Then resolve relative URLs in a following Code node with new URL(path, base).
Is there a better alternative to scraping HTML?
Usually yes. Check the Network tab for the JSON API the page itself calls, or look for a JSON-LD script block containing structured data. Both are more stable than parsing presentation markup that changes with every redesign.