# Detect a tech stack from a GitHub repo URL

> Detect a project's tech stack from a public GitHub repo without cloning. GitHub API branch lookup, parallel raw manifest fetches, and signal mapping.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-23 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/detect-tech-stack-github-repo/

Paste a GitHub URL. Get back framework, database hints, deploy targets — without cloning the repo.

[StackPlan](https://stackplan.dev) does this to prefill its intake form. The scan runs on Cloudflare Workers. No git binary. Just HTTP.

## Parse the URL safely

Never trust raw input. Parse with the `URL` constructor, allow bare `github.com/owner/repo`, strip a `.git` suffix, and reject anything that isn't `github.com`:

```js
function parseGitHubUrl(raw) {
  const trimmed = raw.trim()
  if (!trimmed) return null

  let url
  try {
    url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
  } catch {
    return null
  }

  if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
    return null
  }

  const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/')
  if (parts.length < 2) return null

  const owner = parts[0]
  const repo = parts[1].replace(/\.git$/i, '')
  if (!owner || !repo) return null

  return { owner, repo }
}
```

Reject dot-only repo names. They would break the raw file URL path.

## Resolve the default branch

Manifest files live on a branch. `main` isn't always right.

Hit the GitHub REST API once:

```js
async function resolveDefaultBranch(owner, repo) {
  const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
    headers: {
      Accept: 'application/vnd.github+json',
      'User-Agent': 'MyApp-RepoScan',
    },
  })
  if (!res.ok) return null
  const data = await res.json()
  return data.default_branch ?? null
}
```

Fall back to `main`, then `master`, if the API call fails. Try each branch until you find files.

## Fetch manifests in parallel

Clone would be slow and heavy. **raw.githubusercontent.com** serves file contents over HTTPS:

```
https://raw.githubusercontent.com/{owner}/{repo}/{branch}/package.json
```

Pick a fixed list of high-signal paths: `package.json`, `requirements.txt`, `go.mod`, `wrangler.toml`, `fly.toml`, `Dockerfile`, `prisma/schema.prisma`, and so on. Cap how many you request — twelve is enough for most repos.

Fetch them all with `Promise.all`:

```js
async function fetchRawManifest(owner, repo, branch, path, signal) {
  const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`
  const res = await fetch(url, { signal, headers: { Accept: 'text/plain' } })
  if (!res.ok) return null
  const text = await res.text()
  return text.length > 0 && text.length < 512_000 ? text : null
}

const results = await Promise.all(
  paths.map((path) => fetchRawManifest(owner, repo, branch, path, signal))
)
```

Sequential fetches hurt on 404-heavy repos. Parallel keeps the scan under a few seconds.

Wrap each fetch in a timeout with `AbortController`. Four seconds per file is plenty.

## Map files to stack signals

This part is plain string matching. No LLM required.

From `package.json`, collect dependency names from `dependencies`, `devDependencies`, and `peerDependencies`. Check for `next`, `astro`, `hono`, `@prisma/client`, `drizzle-orm`, `better-auth`, and so on.

From `requirements.txt`, look for `django`, `fastapi`, `celery`. From `go.mod`, look for `gin-gonic/gin`. From config files, infer deploy targets — `wrangler.toml` means Cloudflare Workers, `fly.toml` means Fly.io.

Return a structured object:

```js
{
  language: 'JavaScript/TypeScript',
  framework: 'Astro',
  databases: ['PostgreSQL'],
  orms: ['Drizzle'],
  deployTargets: ['Cloudflare Workers'],
  findings: ['Found package.json', 'Wrangler config found'],
}
```

Turn that into form defaults: app type, needs-database flags, a short assumptions list.

## Rate limits and failures

Unauthenticated GitHub API calls allow sixty requests per hour per IP. A single scan uses one API call plus up to twelve raw fetches. Raw URLs don't count against the REST limit the same way, but don't hammer them in a loop.

If nothing comes back, tell the user the repo is empty, private, or mistyped. Don't throw — return null and let the form stay manual.

An optional LLM pass can refine the app name from a text summary. The deterministic scan already gets you most of the way there.

That's the whole pipeline: validate URL, resolve branch, parallel manifest fetch, rule-based detection. No clone, no GitHub App, no tarball download.
