diff --git a/CHANGELOG.md b/CHANGELOG.md
index 641f2e9..c0f55c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
+- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
+
- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
- **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time.
diff --git a/CLAUDE.md b/CLAUDE.md
index 1573d06..526a0ef 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,7 +76,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`.
- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
-- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
+- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
`dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts
index 6064706..a1b0c33 100644
--- a/__tests__/frameworks.test.ts
+++ b/__tests__/frameworks.test.ts
@@ -1516,6 +1516,7 @@ app.get(
});
import { reactResolver } from '../src/resolution/frameworks/react';
+import { nextjsResolver } from '../src/resolution/frameworks/nextjs';
import { svelteResolver } from '../src/resolution/frameworks/svelte';
import { astroResolver } from '../src/resolution/frameworks/astro';
@@ -1555,13 +1556,14 @@ describe('reactResolver.extract — React Router', () => {
});
it('does not treat config files or a nextjs-pages dir as Next.js routes', () => {
- const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}');
+ const cfg = nextjsResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}');
expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
- const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}');
+ const vite = nextjsResolver.extract!('src/pages/vite.config.ts', 'export default {}');
expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
- // a real page still works
- const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}');
+ // a real page still works — and the React resolver leaves it to the Next one
+ const page = nextjsResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}');
expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']);
+ expect(reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}').nodes).toHaveLength(0);
});
});
diff --git a/__tests__/nextjs.test.ts b/__tests__/nextjs.test.ts
new file mode 100644
index 0000000..86ec3e4
--- /dev/null
+++ b/__tests__/nextjs.test.ts
@@ -0,0 +1,314 @@
+/**
+ * Next.js as a Screens app (`src/resolution/frameworks/nextjs.ts`,
+ * `src/resolution/next-router-synthesizer.ts`): pages from files, route
+ * handlers as endpoints, navigation from ``, `router.push`, `redirect`
+ * and `NextResponse.redirect`, and the Screens / Steps pictures they make.
+ * Mirrors `expo-router.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import { buildSteps } from '../src/ui-server/api/steps';
+import { nextjsResolver, nextRouteForFile, nextNavVerb } from '../src/resolution/frameworks/nextjs';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Route paths from file names
+// =============================================================================
+
+describe('nextjs: nextRouteForFile', () => {
+ it.each([
+ ['app/page.tsx', 'page', '/', ''],
+ ['src/app/users/page.tsx', 'page', '/users', ''],
+ ['apps/web/app/(marketing)/about/page.tsx', 'page', '/about', 'apps/web/'],
+ ['app/blog/[slug]/page.tsx', 'page', '/blog/:slug', ''],
+ ['app/docs/[...all]/page.tsx', 'page', '/docs/:all*', ''],
+ ['app/docs/[[...all]]/page.jsx', 'page', '/docs/:all*', ''],
+ ['app/api/users/route.ts', 'handler', '/api/users', ''],
+ ['app/api/users/[id]/route.ts', 'handler', '/api/users/:id', ''],
+ ['pages/index.tsx', 'page', '/', ''],
+ ['pages/about.tsx', 'page', '/about', ''],
+ ['src/pages/blog/[slug].tsx', 'page', '/blog/:slug', ''],
+ ['pages/api/users.ts', 'api', '/api/users', ''],
+ ['apps/web/pages/api/users/[id].ts', 'api', '/api/users/:id', 'apps/web/'],
+ ])('%s → %s %s (root %s)', (file, kind, route, root) => {
+ expect(nextRouteForFile(file)).toEqual({ kind, path: route, root });
+ });
+
+ it.each([
+ 'app/layout.tsx',
+ 'app/loading.tsx',
+ 'app/users/error.tsx',
+ 'app/@modal/photo/page.tsx',
+ 'app/(.)photo/[id]/page.tsx',
+ 'pages/_app.tsx',
+ 'pages/_document.tsx',
+ 'src/pages/vite.config.ts',
+ 'apps/nextjs-pages/next.config.mjs',
+ 'app/users/__tests__/page.tsx',
+ 'src/components/button.tsx',
+ ])('%s is not a route', (file) => {
+ expect(nextRouteForFile(file)).toBeNull();
+ });
+});
+
+describe('nextjs: extract', () => {
+ it('a page is a route named by its path, calling its default export', () => {
+ const { nodes, references } = nextjsResolver.extract!('app/users/page.tsx', "export default function UsersPage() {\n return null\n}\n");
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toMatchObject({ kind: 'route', name: '/users', language: 'tsx' });
+ expect(references).toEqual([expect.objectContaining({ fromNodeId: nodes[0]!.id, referenceName: 'UsersPage', referenceKind: 'calls', line: 1 })]);
+ });
+
+ it('a route handler file is one endpoint per exported method, each naming its function', () => {
+ const src = "import { NextResponse } from 'next/server'\nexport async function GET() {\n return NextResponse.json([])\n}\nexport const POST = async (req) => {\n return NextResponse.json({}, { status: 201 })\n}\n";
+ const { nodes, references } = nextjsResolver.extract!('app/api/users/route.ts', src);
+ expect(nodes.map((n) => n.name)).toEqual(['GET /api/users', 'POST /api/users']);
+ expect(nodes.map((n) => n.startLine)).toEqual([2, 5]);
+ expect(references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
+ ['GET', 'references'],
+ ['POST', 'references'],
+ ]);
+ });
+
+ it('a Pages Router API file is ANY on its path, bound to the default export', () => {
+ const { nodes, references } = nextjsResolver.extract!('pages/api/users.ts', 'export default async function handler(req, res) {\n res.status(200).json([])\n}\n');
+ expect(nodes.map((n) => n.name)).toEqual(['ANY /api/users']);
+ expect(references[0]).toMatchObject({ referenceName: 'handler', referenceKind: 'references' });
+ });
+
+ it('emits nothing for a layout or a component file', () => {
+ expect(nextjsResolver.extract!('app/layout.tsx', 'export default function L() {}').nodes).toHaveLength(0);
+ expect(nextjsResolver.extract!('components/nav.tsx', 'export default function Nav() {}').nodes).toHaveLength(0);
+ });
+
+ it('claims the navigation calls and names their verb', () => {
+ expect(nextNavVerb('router.push')).toBe('push');
+ expect(nextNavVerb('router.replace')).toBe('replace');
+ expect(nextNavVerb('redirect')).toBe('redirect');
+ expect(nextNavVerb('permanentRedirect')).toBe('permanentRedirect');
+ expect(nextNavVerb('NextResponse.redirect')).toBe('response.redirect');
+ expect(nextNavVerb('router.back')).toBeNull();
+ expect(nextNavVerb('fetch')).toBeNull();
+ expect(nextjsResolver.claimsReference!('redirect')).toBe(true);
+ expect(nextjsResolver.claimsReference!('Redirect')).toBe(false);
+ });
+});
+
+// =============================================================================
+// End to end: a small App Router site
+// =============================================================================
+
+describe('nextjs: end to end', () => {
+ let tmpDir: string;
+ let cg: CodeGraph;
+
+ function write(rel: string, content: string): void {
+ const full = path.join(tmpDir, rel);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, content);
+ }
+
+ beforeAll(async () => {
+ await initGrammars();
+ await loadAllGrammars();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nextjs-'));
+ write('package.json', JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19', '@prisma/client': '5' } }));
+ write('lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
+ write('app/layout.tsx', 'export default function RootLayout({ children }) {\n return children\n}\n');
+ write(
+ 'app/page.tsx',
+ "import Link from 'next/link'\n" +
+ 'export default function Home() {\n' +
+ ' return (\n' +
+ '