diff --git a/scripts/fixtures/nextjs-pages/app/about/page.tsx b/scripts/fixtures/nextjs-pages/app/about/page.tsx
new file mode 100644
index 0000000..45e95ff
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/about/page.tsx
@@ -0,0 +1,3 @@
+export default function About() {
+ return
About
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/api/users/route.ts b/scripts/fixtures/nextjs-pages/app/api/users/route.ts
new file mode 100644
index 0000000..c6a2c99
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/api/users/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ users: [] });
+}
diff --git a/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx b/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx
new file mode 100644
index 0000000..a155be1
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx
@@ -0,0 +1,3 @@
+export default function BlogPost({ params }: { params: { slug: string } }) {
+ return Blog: {params.slug}
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx b/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..bfd46a3
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx
@@ -0,0 +1,3 @@
+export default function Settings() {
+ return Settings
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/default.tsx b/scripts/fixtures/nextjs-pages/app/default.tsx
new file mode 100644
index 0000000..ca4070f
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return Default
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/error.tsx b/scripts/fixtures/nextjs-pages/app/error.tsx
new file mode 100644
index 0000000..63a13bc
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/error.tsx
@@ -0,0 +1,4 @@
+"use client";
+export default function Error() {
+ return Error
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/layout.tsx b/scripts/fixtures/nextjs-pages/app/layout.tsx
new file mode 100644
index 0000000..b3b059d
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/layout.tsx
@@ -0,0 +1,3 @@
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/scripts/fixtures/nextjs-pages/app/loading.tsx b/scripts/fixtures/nextjs-pages/app/loading.tsx
new file mode 100644
index 0000000..fc80ef0
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return Loading...
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/not-found.tsx b/scripts/fixtures/nextjs-pages/app/not-found.tsx
new file mode 100644
index 0000000..6ebeead
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/not-found.tsx
@@ -0,0 +1,3 @@
+export default function NotFound() {
+ return Not found
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/page.tsx b/scripts/fixtures/nextjs-pages/app/page.tsx
new file mode 100644
index 0000000..aa58832
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/page.tsx
@@ -0,0 +1,3 @@
+export default function Home() {
+ return Home
;
+}
diff --git a/scripts/fixtures/nextjs-pages/app/template.tsx b/scripts/fixtures/nextjs-pages/app/template.tsx
new file mode 100644
index 0000000..ae175b8
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/app/template.tsx
@@ -0,0 +1,3 @@
+export default function Template({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/scripts/fixtures/nextjs-pages/next.config.js b/scripts/fixtures/nextjs-pages/next.config.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/next.config.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/scripts/fixtures/nextjs-pages/package.json b/scripts/fixtures/nextjs-pages/package.json
new file mode 100644
index 0000000..bf5b917
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "nextjs-pages-fixture",
+ "dependencies": {
+ "next": "14.0.0",
+ "react": "18.0.0"
+ }
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/_app.tsx b/scripts/fixtures/nextjs-pages/pages/_app.tsx
new file mode 100644
index 0000000..39b86cd
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/_app.tsx
@@ -0,0 +1,3 @@
+export default function App({ Component, pageProps }) {
+ return ;
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/_document.tsx b/scripts/fixtures/nextjs-pages/pages/_document.tsx
new file mode 100644
index 0000000..4435f49
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/_document.tsx
@@ -0,0 +1,4 @@
+import { Html, Head, Main, NextScript } from "next/document";
+export default function Document() {
+ return ();
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/about/index.tsx b/scripts/fixtures/nextjs-pages/pages/about/index.tsx
new file mode 100644
index 0000000..45e95ff
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/about/index.tsx
@@ -0,0 +1,3 @@
+export default function About() {
+ return About
;
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/api/health.ts b/scripts/fixtures/nextjs-pages/pages/api/health.ts
new file mode 100644
index 0000000..67c99d5
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/api/health.ts
@@ -0,0 +1,3 @@
+export default function handler(req, res) {
+ res.status(200).json({ ok: true });
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx b/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx
new file mode 100644
index 0000000..577fea6
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx
@@ -0,0 +1,3 @@
+export default function BlogPost() {
+ return Blog Post
;
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/index.tsx b/scripts/fixtures/nextjs-pages/pages/index.tsx
new file mode 100644
index 0000000..aa58832
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/index.tsx
@@ -0,0 +1,3 @@
+export default function Home() {
+ return Home
;
+}
diff --git a/scripts/fixtures/nextjs-pages/pages/products/index.tsx b/scripts/fixtures/nextjs-pages/pages/products/index.tsx
new file mode 100644
index 0000000..b94821f
--- /dev/null
+++ b/scripts/fixtures/nextjs-pages/pages/products/index.tsx
@@ -0,0 +1,3 @@
+export default function Products() {
+ return Products
;
+}
diff --git a/src/extractors/fastapi.ts b/src/extractors/fastapi.ts
index 0524a6d..b2a3d5c 100644
--- a/src/extractors/fastapi.ts
+++ b/src/extractors/fastapi.ts
@@ -59,7 +59,8 @@ export const fastapi: Extractor = {
const funcParams = m[6]!;
const line = lines.lineAt(m.index);
- if (httpMethod === "WEBSOCKET") httpMethod = "WS";
+ const isWebSocket = httpMethod === "WEBSOCKET";
+ if (isWebSocket) httpMethod = "WS";
const prefix = routerPrefixes[varName] ?? "";
const fullPath = normalizePath(prefix + routePath);
@@ -83,6 +84,7 @@ export const fastapi: Extractor = {
endpoints.push(
endpoint({
method: httpMethod,
+ kind: isWebSocket ? "websocket" : "api",
path: fullPath,
handler: funcName,
file: rel,
diff --git a/src/extractors/nextjs.test.ts b/src/extractors/nextjs.test.ts
new file mode 100644
index 0000000..c83cb22
--- /dev/null
+++ b/src/extractors/nextjs.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test";
+import { resolve } from "path";
+import { nextjs } from "./nextjs.ts";
+import { createScanContext } from "../scan-context.ts";
+
+const FIXTURE = resolve(import.meta.dir, "../../scripts/fixtures/nextjs-pages");
+
+function extract(fixturePath: string = FIXTURE) {
+ const ctx = createScanContext(fixturePath);
+ return nextjs.extract(ctx);
+}
+
+describe("nextjs page route extraction", () => {
+ const endpoints = extract();
+
+ // ---- App Router pages ----
+
+ test("extracts app router root page (/)", () => {
+ const ep = endpoints.find(
+ (e) => e.kind === "page" && e.path === "/" && e.file.includes("app/page"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ expect(ep!.handler).toBe("Page");
+ expect(ep!.framework).toBe("nextjs");
+ });
+
+ test("extracts app router /about page", () => {
+ const ep = endpoints.find(
+ (e) =>
+ e.kind === "page" && e.path === "/about" && e.file.includes("app/"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ });
+
+ test("extracts app router dynamic [slug] page", () => {
+ const ep = endpoints.find(
+ (e) => e.kind === "page" && e.path === "/blog/[slug]",
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ expect(ep!.params.length).toBeGreaterThan(0);
+ expect(ep!.params[0]!.name).toBe("slug");
+ });
+
+ test("extracts app router nested page", () => {
+ const ep = endpoints.find(
+ (e) => e.kind === "page" && e.path === "/dashboard/settings",
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ });
+
+ test("excludes layout.tsx from app router pages", () => {
+ const layouts = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("layout"),
+ );
+ expect(layouts).toHaveLength(0);
+ });
+
+ test("excludes loading.tsx from app router pages", () => {
+ const loading = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("loading"),
+ );
+ expect(loading).toHaveLength(0);
+ });
+
+ test("excludes error.tsx from app router pages", () => {
+ const errors = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("error"),
+ );
+ expect(errors).toHaveLength(0);
+ });
+
+ test("excludes not-found.tsx from app router pages", () => {
+ const notFounds = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("not-found"),
+ );
+ expect(notFounds).toHaveLength(0);
+ });
+
+ test("excludes template.tsx from app router pages", () => {
+ const templates = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("template"),
+ );
+ expect(templates).toHaveLength(0);
+ });
+
+ test("excludes default.tsx from app router pages", () => {
+ const defaults = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("default"),
+ );
+ expect(defaults).toHaveLength(0);
+ });
+
+ // ---- App Router API routes still work ----
+
+ test("still extracts app router API routes", () => {
+ const ep = endpoints.find(
+ (e) => e.kind === "api" && e.path === "/api/users",
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ expect(ep!.handler).toBe("GET");
+ });
+
+ // ---- Pages Router pages ----
+
+ test("extracts pages router root page (pages/index.tsx → /)", () => {
+ const ep = endpoints.find(
+ (e) =>
+ e.kind === "page" && e.path === "/" && e.file.includes("pages/index"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ expect(ep!.handler).toBe("Page");
+ });
+
+ test("extracts pages router /about (pages/about/index.tsx → /about)", () => {
+ const ep = endpoints.find(
+ (e) =>
+ e.kind === "page" &&
+ e.path === "/about" &&
+ e.file.includes("pages/about"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ });
+
+ test("extracts pages router dynamic route (pages/blog/[slug].tsx → /blog/[slug])", () => {
+ const ep = endpoints.find(
+ (e) =>
+ e.kind === "page" &&
+ e.path === "/blog/[slug]" &&
+ e.file.includes("pages/blog"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ expect(ep!.params.length).toBeGreaterThan(0);
+ expect(ep!.params[0]!.name).toBe("slug");
+ });
+
+ test("extracts pages router /products (pages/products/index.tsx → /products)", () => {
+ const ep = endpoints.find(
+ (e) =>
+ e.kind === "page" &&
+ e.path === "/products" &&
+ e.file.includes("pages/products"),
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("GET");
+ });
+
+ test("excludes _app.tsx from pages router pages", () => {
+ const apps = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("_app"),
+ );
+ expect(apps).toHaveLength(0);
+ });
+
+ test("excludes _document.tsx from pages router pages", () => {
+ const docs = endpoints.filter(
+ (e) => e.kind === "page" && e.file.includes("_document"),
+ );
+ expect(docs).toHaveLength(0);
+ });
+
+ test("does not emit pages/api/ files as pages (handled as API)", () => {
+ const apiPages = endpoints.filter(
+ (e) => e.kind === "page" && e.path.startsWith("/api"),
+ );
+ expect(apiPages).toHaveLength(0);
+ });
+
+ test("still extracts pages/api/ as API endpoints", () => {
+ const ep = endpoints.find(
+ (e) => e.kind === "api" && e.path === "/api/health",
+ );
+ expect(ep).toBeDefined();
+ expect(ep!.method).toBe("ANY");
+ expect(ep!.handler).toBe("default");
+ });
+
+ // ---- Kind validation ----
+
+ test("all page endpoints have kind=page", () => {
+ const pages = endpoints.filter((e) => e.handler === "Page");
+ expect(pages.length).toBeGreaterThan(0);
+ for (const p of pages) {
+ expect(p.kind).toBe("page");
+ }
+ });
+
+ test("all page endpoints have method=GET", () => {
+ const pages = endpoints.filter((e) => e.kind === "page");
+ for (const p of pages) {
+ expect(p.method).toBe("GET");
+ }
+ });
+});
diff --git a/src/extractors/nextjs.ts b/src/extractors/nextjs.ts
index bd80836..dc6ebef 100644
--- a/src/extractors/nextjs.ts
+++ b/src/extractors/nextjs.ts
@@ -1,5 +1,5 @@
import { readdirSync, existsSync, lstatSync, realpathSync } from "fs";
-import { join, relative } from "path";
+import { join, relative, basename } from "path";
import type { EndpointInfo, Extractor } from "../types.ts";
import {
endpoint,
@@ -9,6 +9,23 @@ import {
} from "../utils.ts";
import { SKIP_DIRS } from "../scan-context.ts";
+const PAGE_EXTENSIONS = /\.(tsx|jsx|ts|js)$/;
+const APP_ROUTER_PAGE_RE = /^page\.(tsx|jsx|ts|js)$/;
+const APP_ROUTER_LAYOUT_FILES = new Set([
+ "layout",
+ "template",
+ "loading",
+ "error",
+ "not-found",
+ "default",
+]);
+const PAGES_ROUTER_SPECIAL_FILES = new Set([
+ "_app",
+ "_document",
+ "_error",
+ "_middleware",
+]);
+
function findNextConfigs(root: string, maxDepth = 3): string[] {
const results: string[] = [];
function walk(dir: string, depth: number) {
@@ -143,6 +160,32 @@ export const nextjs: Extractor = {
}
}
+ // App Router: app/**/page.{tsx,jsx,ts,js}
+ for (const baseName of ["app", join("src", "app")]) {
+ const base = join(appRoot, baseName);
+ if (!existsSync(base)) continue;
+
+ for (const pf of walkFiles(base, (n) => APP_ROUTER_PAGE_RE.test(n))) {
+ ctx.filesScanned++;
+ const rel = ctx.rel(pf);
+ const pageDir = pf.replace(/\/page\.(tsx|jsx|ts|js)$/, "");
+ const urlPath = normalizePath("/" + relative(base, pageDir));
+
+ endpoints.push(
+ endpoint({
+ method: "GET",
+ kind: "page",
+ path: urlPath,
+ handler: "Page",
+ file: rel,
+ line: 1,
+ framework: "nextjs",
+ params: extractPathParams(urlPath),
+ }),
+ );
+ }
+ }
+
// Pages Router: pages/api/**/*
for (const baseName of ["pages", join("src", "pages")]) {
const pagesApi = join(appRoot, baseName, "api");
@@ -168,6 +211,54 @@ export const nextjs: Extractor = {
);
}
}
+
+ // Pages Router: pages/**/*.{tsx,jsx,ts,js} (excluding api/ and special files)
+ for (const baseName of ["pages", join("src", "pages")]) {
+ const pagesBase = join(appRoot, baseName);
+ if (!existsSync(pagesBase)) continue;
+
+ for (const pf of walkFiles(pagesBase, (n) => PAGE_EXTENSIONS.test(n))) {
+ const relToPages = relative(pagesBase, pf);
+ // Skip pages/api/** — already handled above
+ if (relToPages.startsWith("api/") || relToPages.startsWith("api\\"))
+ continue;
+
+ const stem = basename(pf).replace(PAGE_EXTENSIONS, "");
+ // Skip special Next.js files
+ if (PAGES_ROUTER_SPECIAL_FILES.has(stem)) continue;
+ // Skip layout-like files in pages router
+ if (APP_ROUTER_LAYOUT_FILES.has(stem)) continue;
+
+ ctx.filesScanned++;
+ const rel = ctx.rel(pf);
+
+ // Derive URL path: strip extension, normalize /index → /
+ let urlSegment = relToPages.replace(PAGE_EXTENSIONS, "");
+ // Normalize path separators (Windows compat)
+ urlSegment = urlSegment.replace(/\\/g, "/");
+ // /index at any level → parent directory path
+ if (urlSegment === "index") {
+ urlSegment = "";
+ } else if (urlSegment.endsWith("/index")) {
+ urlSegment = urlSegment.slice(0, -"/index".length);
+ }
+ // Convert [param] to :param for bracket params
+ const urlPath = normalizePath("/" + urlSegment);
+
+ endpoints.push(
+ endpoint({
+ method: "GET",
+ kind: "page",
+ path: urlPath,
+ handler: "Page",
+ file: rel,
+ line: 1,
+ framework: "nextjs",
+ params: extractPathParams(urlPath),
+ }),
+ );
+ }
+ }
}
return endpoints;
diff --git a/src/extractors/server-actions.ts b/src/extractors/server-actions.ts
index ba4aaa2..ce3c0f6 100644
--- a/src/extractors/server-actions.ts
+++ b/src/extractors/server-actions.ts
@@ -218,6 +218,7 @@ export const serverActions: Extractor = {
endpoints.push(
endpoint({
method: "ACTION",
+ kind: "action",
path: `/${moduleName}/${exp.name}`,
handler: exp.name,
file: rel,
diff --git a/src/format-impact.ts b/src/format-impact.ts
index 07fcd5d..3820fc2 100644
--- a/src/format-impact.ts
+++ b/src/format-impact.ts
@@ -108,6 +108,7 @@ export function formatImpactJson(result: ImpactResult): string {
affected: result.affected.map((a) => {
const obj: Record = {
method: a.endpoint.method,
+ kind: a.endpoint.kind,
path: a.endpoint.path,
handler: a.endpoint.handler,
file: a.endpoint.file,
@@ -151,6 +152,7 @@ export function formatImpactNdjson(result: ImpactResult): string {
for (const a of result.affected) {
const obj: Record = {
method: a.endpoint.method,
+ kind: a.endpoint.kind,
path: a.endpoint.path,
handler: a.endpoint.handler,
file: a.endpoint.file,
diff --git a/src/format.ts b/src/format.ts
index ddfcbd2..2d3cdb8 100644
--- a/src/format.ts
+++ b/src/format.ts
@@ -212,6 +212,7 @@ export function formatJson(result: MapResult, options?: FormatOptions): string {
endpoints: endpoints.all.map((e) => {
const obj: Record = {
method: e.method,
+ kind: e.kind,
path: e.path,
handler: e.handler,
file: e.file,
@@ -263,6 +264,7 @@ export function formatNdjson(
for (const e of endpoints) {
const obj: Record = {
method: e.method,
+ kind: e.kind,
path: e.path,
handler: e.handler,
file: e.file,
diff --git a/src/index.ts b/src/index.ts
index 546b8e5..52779a7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ export type {
AffectedEndpoint,
DiffHunk,
EndpointInfo,
+ EndpointKind,
FunctionDef,
FrameworkId,
HttpMethod,
diff --git a/src/types.ts b/src/types.ts
index aafb80d..b992df0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -19,6 +19,8 @@ export type HttpMethod =
| "WS"
| "ACTION";
+export type EndpointKind = "api" | "page" | "action" | "websocket";
+
export type ServiceType =
| "nextjs"
| "lambda"
@@ -37,6 +39,7 @@ export interface ServiceInfo {
export interface EndpointInfo {
method: HttpMethod;
+ kind: EndpointKind;
path: string;
handler: string;
file: string;
diff --git a/src/utils.ts b/src/utils.ts
index 8237e7d..39d82c0 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,5 +1,6 @@
import type {
EndpointInfo,
+ EndpointKind,
FrameworkId,
HttpMethod,
ParamInfo,
@@ -100,6 +101,7 @@ export function endpoint(e: {
file: string;
line: number;
framework: FrameworkId;
+ kind?: EndpointKind;
params?: ParamInfo[];
auth?: string[];
service?: string;
@@ -108,6 +110,7 @@ export function endpoint(e: {
}): EndpointInfo {
const ep: EndpointInfo = {
method: e.method as HttpMethod,
+ kind: e.kind ?? "api",
path: e.path,
handler: e.handler,
file: e.file,
diff --git a/tsconfig.json b/tsconfig.json
index bfa0fea..d1ea496 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,5 +25,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
- }
+ },
+ "exclude": ["scripts/fixtures"]
}