The obvious way to work out what a site is built with is to fetch the HTML and look for telltale strings. React, Next, Shopify, Cloudflare. It works often enough to feel like it works, which is the problem.
Here is a live example. Fetch vercel.com and grep for svelte:
curl -sSL https://vercel.com | grep -oi svelte | wc -l
# 2
Two hits. Vercel does not run SvelteKit. Both matches come from a footer link:
<a href="/docs/frameworks/full-stack/sveltekit" …>SvelteKit</a>
It is a docs nav item. Any detector built on "does the page contain the word" reports SvelteKit on a site that runs Next.js, and you would never notice, because the output looks exactly like a real detection.
That is the whole difficulty. Not fetching. Distinguishing a technology that is running from a word that happens to appear.
What actually identifies a stack
Five signal types, in rough order of how much I trust them:
1. Response headers. The most under-used and often the most precise.
curl -sSI https://vercel.com | grep -i "server\|x-powered-by"
# Server: Vercel
# X-Powered-By: Next.js, Payload
That single header names both the framework and the CMS. Payload is not visible anywhere in the rendered page — no asset path, no DOM marker, no JS global. One header, one detection you cannot get any other way.
2. Asset origins. What the page loads, and from where. cdn.shopify.com on a page means Shopify is serving it, in a way that a mention of the word "Shopify" does not:
curl -sSL https://www.allbirds.com | grep -o "cdn\.shopify" | wc -l
# 13
Compare that with 83 occurrences of the bare string Shopify on the same page — most of them copy, schema markup, and script variable names. The 13 asset references are the evidence; the 83 are noise that happens to correlate. (Both counts drift every time they ship a homepage change — I watched them move while writing this. The ratio is the durable part, not the numbers.)
3. Cookies. _shopify_y, wordpress_logged_in, ASP.NET_SessionId. Hard to fake incidentally.
4. DOM markers. [data-reactroot], #__next, [data-svelte], wp-content paths in the markup.
5. JS globals after hydration. window.__NUXT__, window.__NEXT_DATA__, window.Shopify, React on window. This is the tier that needs a real browser.
Where static fetching genuinely runs out
I want to be fair here, because "you need a headless browser" is over-claimed. Static fetching gets you further than people assume — Next.js sites usually announce themselves:
curl -sSL https://www.notion.so | grep -c "__NEXT_DATA__" # 1
curl -sSI https://www.notion.so | grep -i x-powered-by # x-powered-by: Next.js
Both work with no browser at all.
Where it does break down is client-rendered SPAs that ship an empty shell. If the server returns <div id="root"></div> and everything else arrives via JS, there is nothing in the static HTML to match — the framework only becomes visible once the bundle executes and mutates the DOM. Same for anything injected by a tag manager, which is most analytics and chat widgets on a lot of sites.
So the honest split: headers and asset origins cover a surprising amount. A browser is what you need for the client-side tier, and for avoiding the false-positive trap by checking what the page did rather than what it said.
The DIY version
A decent detector is a few hundred lines:
const res = await fetch(url, { redirect: 'follow' });
const html = await res.text();
const hits = [];
// headers — highest confidence
const powered = res.headers.get('x-powered-by');
if (powered) hits.push({ name: powered, category: 'framework', evidence: `header: x-powered-by: ${powered}` });
// asset origins, NOT bare strings
if (/cdn\.shopify\.com/.test(html))
hits.push({ name: 'Shopify', category: 'ecommerce', evidence: 'asset: cdn.shopify.com' });
// DOM markers
if (/<div id="__next"/.test(html))
hits.push({ name: 'Next.js', category: 'framework', evidence: 'dom: #__next' });
Note the shape: every hit carries the reason it fired. That is not decoration. Without it you cannot tell a real detection from a footer link, and you will not find out which you have until someone acts on it.
The ongoing cost is the part people underestimate. Signatures rot — frameworks change their markers between majors, data-reactroot disappeared in React 18, CDNs get renamed, hosts add and drop headers. A detector is a maintenance commitment, not a weekend project.
The one-call version
Tech Stack Detector renders each site in a real browser and returns one row per site, with the evidence for every match:
curl -X POST "https://api.apify.com/v2/acts/fetchbase~tech-stack-detector/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "urls": ["https://www.allbirds.com", "https://nextjs.org"] }'
{
"url": "https://www.allbirds.com",
"techCount": 14,
"cms": "Shopify",
"frameworks": "React",
"analytics": "Google Analytics (GA4), Hotjar",
"hosting": "Cloudflare",
"technologies": [
{ "name": "Shopify", "category": "ecommerce", "evidence": "asset: https://cdn.shopify.com/…" },
{ "name": "React", "category": "framework", "evidence": "dom: [data-reactroot]" },
{ "name": "Stripe", "category": "payments", "evidence": "asset: https://js.stripe.com/v3" }
]
}
Around 100 technologies across CMS, e-commerce, JS frameworks, analytics, marketing, CDN and hosting, server and language, payments, and consent tooling. Charged per site analysed; failures cost nothing.
It is also callable as an MCP tool, which is genuinely the more interesting use — an agent asked "what is this company running" can go and find out rather than guessing from training data.
Three ready-made variants if you want to skip the input wiring:
Detect any website's tech stack — the general sweep, evidence included
Check whether a site runs WordPress — the single-question version
Find out if a store is on Shopify — asset-origin check, not a word search
The underrated thing
If you only do one thing from this post: read x-powered-by before you parse any HTML.
It is one header, most detectors treat it as an afterthought, and it gave up Vercel's CMS — Payload — which is invisible by every other method. For competitive research that is often the single highest-value field on the whole response, and it costs you a HEAD request.
The corollary, and the reason I wrote this: any detection without evidence attached is a guess wearing a confident face. Vercel "uses SvelteKit" is what you get otherwise, and it looks identical to a real answer.
Top comments (0)