Last checked

How to find what tech stack a website uses

You can identify a lot of a public site’s stack from a single HTTP response. Read the Server, X-Powered-By, and cf-ray headers, the HTML generator meta tag, and the hostnames of script and stylesheet URLs. A page that references cdn.shopify.com is a Shopify storefront. A path under /wp-content/ is WordPress. A /_next/static/ path is Next.js.

That is a reading of what the site already sends. It is not a vulnerability scan, and it misses tags that appear only after JavaScript runs in a browser.

In short

  1. Request the public page and keep the status, the final URL, and the response headers.
  2. Server, X-Powered-By, and cf-ray are direct statements from the host.
  3. Check the generator meta tag and the hostnames in script and stylesheet URLs.
  4. Keep the signal that justified each name, and leave unknown markup unnamed.

What one response can tell you

These signals are deliberately obvious. If you cannot point at the bytes that justified a name, do not report the name.

If you see thisIt supports
Response header cf-ray, or server: cloudflareCloudflare is on the request path
x-powered-by: PHP or similarThe value the origin chose to send
<meta name="generator" content="WordPress …">That generator
/wp-content/ or /wp-includes/ in a URLWordPress
cdn.shopify.comShopify storefront assets
/_next/static/Next.js
googletagmanager.com/gtm.jsGoogle Tag Manager
fonts.googleapis.comGoogle Fonts stylesheets

A CDN in front of a site is not the application. Cloudflare on example.com tells you about the edge, not about the page’s framework. Report them as separate facts.

A small detector

The Python below uses the standard library. It follows redirects, then applies the table above. It does not execute JavaScript and it does not retry aggressively.

from html.parser import HTMLParser
from urllib.request import Request, urlopen

SIGNATURES = (
    ("cloudflare", "Cloudflare"),
    ("cdn.shopify.com", "Shopify"),
    ("/wp-content/", "WordPress"),
    ("/wp-includes/", "WordPress"),
    ("/_next/static/", "Next.js"),
    ("googletagmanager.com/gtm.js", "Google Tag Manager"),
    ("fonts.googleapis.com", "Google Fonts"),
)

class HintParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.generator = None
        self.urls = []

    def handle_starttag(self, tag, attrs):
        data = dict(attrs)
        if tag == "meta" and data.get("name", "").lower() == "generator":
            self.generator = data.get("content")
        href = data.get("src") or data.get("href")
        if href:
            self.urls.append(href)

def detect(url):
    request = Request(url, headers={"User-Agent": "tech-stack-example/1.0"})
    with urlopen(request, timeout=20) as response:
        final_url = response.geturl()
        headers = {k.lower(): v for k, v in response.headers.items()}
        html = response.read(1_000_000).decode("utf-8", errors="replace")

    found = []
    if "cf-ray" in headers or "cloudflare" in headers.get("server", "").lower():
        found.append({"name": "Cloudflare", "evidence": "response header"})
    powered = headers.get("x-powered-by")
    if powered:
        found.append({"name": powered, "evidence": "x-powered-by"})

    parser = HintParser()
    parser.feed(html)
    if parser.generator:
        found.append({"name": parser.generator, "evidence": "meta generator"})

    blob = "\n".join(parser.urls).lower()
    seen = {item["name"] for item in found}
    for needle, name in SIGNATURES:
        if needle in blob and name not in seen:
            found.append({"name": name, "evidence": needle})
            seen.add(name)

    return {"url": url, "finalUrl": final_url, "technologies": found}

if __name__ == "__main__":
    import json
    print(json.dumps(detect("https://example.com"), indent=2))

Node.js, same idea:

const needles = [
  ["cdn.shopify.com", "Shopify"],
  ["/wp-content/", "WordPress"],
  ["/wp-includes/", "WordPress"],
  ["/_next/static/", "Next.js"],
  ["googletagmanager.com/gtm.js", "Google Tag Manager"],
  ["fonts.googleapis.com", "Google Fonts"],
];

export async function detect(url) {
  const response = await fetch(url, {
    redirect: "follow",
    headers: { "User-Agent": "tech-stack-example/1.0" },
  });
  const html = (await response.text()).slice(0, 1_000_000);
  const found = [];

  const server = response.headers.get("server") ?? "";
  if (response.headers.get("cf-ray") || /cloudflare/i.test(server)) {
    found.push({ name: "Cloudflare", evidence: "response header" });
  }
  const powered = response.headers.get("x-powered-by");
  if (powered) found.push({ name: powered, evidence: "x-powered-by" });

  const generator = html.match(
    /<meta[^>]+name=["']generator["'][^>]+content=["']([^"']+)["']/i,
  );
  if (generator) found.push({ name: generator[1], evidence: "meta generator" });

  const urls = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/gi)].map((match) =>
    match[1].toLowerCase(),
  );
  const blob = urls.join("\n");
  for (const [needle, name] of needles) {
    if (blob.includes(needle) && !found.some((item) => item.name === name)) {
      found.push({ name, evidence: needle });
    }
  }

  return { url, finalUrl: response.url, technologies: found };
}

console.log(await detect("https://example.com"));

Checked on 12 October 2026, https://example.com returned server: cloudflare and a cf-ray header. The script reports Cloudflare and does not invent a framework for that page, because the HTML does not contain one of the signatures above.

What this misses

Client-rendered apps can ship an almost empty first HTML document. The framework then adds tags in the browser. A fetch of the first document will not see those tags.

Minified or proxied assets also hide names. A first-party bundle will not say “React” in the URL. Looking for react inside a minified file produces false positives, so this guide does not do that.

Do not turn the detector into a scanner. Request each URL once, identify yourself with a user agent, and stop. Checking whether an admin path exists, guessing default passwords, or probing old plugins is a different and unwelcome activity.

A maintained list instead of a script

The table in this guide is short on purpose. Adding hundreds of signatures means keeping them current and deciding what the evidence threshold is.

The Website Tech Stack Detector does that fetch for a list of public URLs and returns each technology with the evidence that supported it. It still does not run the page’s JavaScript, and it is not a security product. The price is $1.50 per 1,000 URLs.

Written with AI assistance and checked by the Lepidata team.