diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore
new file mode 100644
index 000000000000..0c60c8eeaee8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore
@@ -0,0 +1,48 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+!*.d.ts
+
+# Sentry
+.sentryclirc
+
+.vscode
+
+test-results
+event-dumps
+
+.tmp_dev_server_logs
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc
new file mode 100644
index 000000000000..a3160f4de175
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc
@@ -0,0 +1,4 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
+public-hoist-pattern[]=*import-in-the-middle*
+public-hoist-pattern[]=*require-in-the-middle*
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx
new file mode 100644
index 000000000000..dab69e234139
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return
hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx
new file mode 100644
index 000000000000..dab69e234139
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx
new file mode 100644
index 000000000000..c8f9cee0b787
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx
new file mode 100644
index 000000000000..de789f9af524
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx
@@ -0,0 +1,5 @@
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx
new file mode 100644
index 000000000000..de789f9af524
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx
@@ -0,0 +1,5 @@
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx
new file mode 100644
index 000000000000..918c03de3d0a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+export default function Page() {
+ const router = useRouter();
+
+ return (
+
+ -
+
+
+ -
+ Normal Link
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx
new file mode 100644
index 000000000000..4f3c471f2ad3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx
@@ -0,0 +1,7 @@
+export default function Page() {
+ return (
+
+
Nextjs basePath Test App
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts
new file mode 100644
index 000000000000..109dbcd55648
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts
@@ -0,0 +1,4 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts
new file mode 100644
index 000000000000..4870c64e7959
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
+
+export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts
new file mode 100644
index 000000000000..964f937c439a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/nextjs';
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ await import('./sentry.server.config');
+ }
+
+ if (process.env.NEXT_RUNTIME === 'edge') {
+ await import('./sentry.edge.config');
+ }
+}
+
+export const onRequestError = Sentry.captureRequestError;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts
new file mode 100644
index 000000000000..4f11a03dc6cc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js
new file mode 100644
index 000000000000..591aec7c1ce0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js
@@ -0,0 +1,10 @@
+const { withSentryConfig } = require('@sentry/nextjs');
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ basePath: '/my-app',
+};
+
+module.exports = withSentryConfig(nextConfig, {
+ silent: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json
new file mode 100644
index 000000000000..48a0c69ae38a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "nextjs-15-basepath",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
+ "test:prod": "TEST_ENV=production playwright test",
+ "test:dev": "TEST_ENV=development playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test:prod && pnpm test:dev"
+ },
+ "dependencies": {
+ "@sentry/nextjs": "latest || *",
+ "@types/node": "^18.19.1",
+ "@types/react": "18.0.26",
+ "@types/react-dom": "18.0.9",
+ "next": "15.4.2-canary.1",
+ "react": "beta",
+ "react-dom": "beta",
+ "typescript": "~5.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.53.2",
+ "@sentry-internal/test-utils": "link:../../../test-utils"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs
new file mode 100644
index 000000000000..38548e975851
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs
@@ -0,0 +1,25 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+const testEnv = process.env.TEST_ENV;
+
+if (!testEnv) {
+ throw new Error('No test env defined');
+}
+
+const getStartCommand = () => {
+ if (testEnv === 'development') {
+ return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs';
+ }
+
+ if (testEnv === 'production') {
+ return 'pnpm next start -p 3030';
+ }
+
+ throw new Error(`Unknown test env: ${testEnv}`);
+};
+
+const config = getPlaywrightConfig({
+ startCommand: getStartCommand(),
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts
new file mode 100644
index 000000000000..067d2ead0b8b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+ transportOptions: {
+ // We are doing a lot of events at once in this test
+ bufferSize: 1000,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts
new file mode 100644
index 000000000000..067d2ead0b8b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+ transportOptions: {
+ // We are doing a lot of events at once in this test
+ bufferSize: 1000,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs
new file mode 100644
index 000000000000..e8834a451788
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs
@@ -0,0 +1,14 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nextjs-15-basepath',
+ envelopeDumpPath: path.join(
+ process.cwd(),
+ `event-dumps/next-15-basepath-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
+ ),
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts
new file mode 100644
index 000000000000..12fe814d2fff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts
@@ -0,0 +1,74 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Creates a pageload transaction for basePath root route with prefix', async ({ page }) => {
+ const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => {
+ return transactionEvent?.transaction === '/my-app' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/my-app');
+
+ expect(await clientPageloadTransactionPromise).toBeDefined();
+});
+
+test('Creates a dynamic pageload transaction for basePath dynamic route with prefix', async ({ page }) => {
+ const randomRoute = String(Math.random());
+
+ const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => {
+ return (
+ transactionEvent?.transaction === '/my-app/dynamic/:parameter' &&
+ transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/my-app/dynamic/${randomRoute}`);
+
+ expect(await clientPageloadTransactionPromise).toBeDefined();
+});
+
+test('Creates a dynamic pageload transaction for basePath dynamic catch-all route with prefix', async ({ page }) => {
+ const randomRoute = String(Math.random());
+
+ const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => {
+ return (
+ transactionEvent?.transaction === '/my-app/dynamic/:parameters*' &&
+ transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/my-app/dynamic/${randomRoute}/foo/bar/baz`);
+
+ expect(await clientPageloadTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for basePath router with prefix', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => {
+ return (
+ transactionEvent?.transaction === '/my-app/navigation/:param/router-push' &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
+ );
+ });
+
+ await page.goto('/my-app/navigation');
+ await page.waitForTimeout(1000);
+ await page.getByText('router.push()').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for basePath with prefix', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => {
+ return (
+ transactionEvent?.transaction === '/my-app/navigation/:param/link' &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
+ );
+ });
+
+ await page.goto('/my-app/navigation');
+ await page.waitForTimeout(1000);
+ await page.getByText('Normal Link').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json
new file mode 100644
index 000000000000..a2672ddb4974
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "allowImportingTsExtensions": true,
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "incremental": true
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", "playwright.config.ts"]
+}
diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
index 425daeb3e558..4006496d4a23 100644
--- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
@@ -72,6 +72,10 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
};
};
+const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ _sentryBasePath: string | undefined;
+};
+
/*
* The routing instrumentation needs to handle a few cases:
* - Router operations:
@@ -87,7 +91,9 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
/** Instruments the Next.js app router for navigation. */
export function appRouterInstrumentNavigation(client: Client): void {
routerTransitionHandler = (href, navigationType) => {
- const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname;
+ const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
+ const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href;
+ const unparameterizedPathname = new URL(normalizedHref, WINDOW.location.href).pathname;
const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname);
const pathname = parameterizedPathname ?? unparameterizedPathname;
@@ -206,11 +212,15 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
};
+ const href = argArray[0];
+ const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
+ const normalizedHref =
+ basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href;
if (routerFunctionName === 'push') {
- transactionName = transactionNameifyRouterArgument(argArray[0]);
+ transactionName = transactionNameifyRouterArgument(normalizedHref);
transactionAttributes['navigation.type'] = 'router.push';
} else if (routerFunctionName === 'replace') {
- transactionName = transactionNameifyRouterArgument(argArray[0]);
+ transactionName = transactionNameifyRouterArgument(normalizedHref);
transactionAttributes['navigation.type'] = 'router.replace';
} else if (routerFunctionName === 'back') {
transactionAttributes['navigation.type'] = 'router.back';
diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts
index 1e905d858f73..32e7db61b57b 100644
--- a/packages/nextjs/src/config/manifest/createRouteManifest.ts
+++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts
@@ -10,6 +10,10 @@ export type CreateRouteManifestOptions = {
* By default, route groups are stripped from paths following Next.js convention.
*/
includeRouteGroups?: boolean;
+ /**
+ * Base path for the application, if any. This will be prefixed to all routes.
+ */
+ basePath?: string;
};
let manifestCache: RouteManifest | null = null;
@@ -192,7 +196,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route
return manifestCache;
}
- const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups);
+ const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, options?.basePath, options?.includeRouteGroups);
const manifest: RouteManifest = {
dynamicRoutes,
diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts
index ddf761998e50..9c82e3af017c 100644
--- a/packages/nextjs/src/config/withSentryConfig.ts
+++ b/packages/nextjs/src/config/withSentryConfig.ts
@@ -147,7 +147,9 @@ function getFinalConfigObject(
let routeManifest: RouteManifest | undefined;
if (!userSentryOptions.disableManifestInjection) {
- routeManifest = createRouteManifest();
+ routeManifest = createRouteManifest({
+ basePath: incomingUserNextConfigObject.basePath,
+ });
}
setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName);
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx
new file mode 100644
index 000000000000..e5752fa903b7
--- /dev/null
+++ b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx
@@ -0,0 +1 @@
+// about page
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx
new file mode 100644
index 000000000000..ec89ef596f93
--- /dev/null
+++ b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx
@@ -0,0 +1 @@
+// API test page
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx
new file mode 100644
index 000000000000..768d7a4f7757
--- /dev/null
+++ b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx
@@ -0,0 +1 @@
+// root page
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx
new file mode 100644
index 000000000000..a7307090717b
--- /dev/null
+++ b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx
@@ -0,0 +1 @@
+// users id dynamic page
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts
new file mode 100644
index 000000000000..a1014b05c32c
--- /dev/null
+++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts
@@ -0,0 +1,45 @@
+import path from 'path';
+import { describe, expect, test } from 'vitest';
+import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest';
+
+describe('basePath', () => {
+ test('should generate routes with base path prefix', () => {
+ const manifest = createRouteManifest({
+ basePath: '/my-app',
+ appDirPath: path.join(__dirname, 'app'),
+ });
+
+ expect(manifest).toEqual({
+ staticRoutes: [{ path: '/my-app' }, { path: '/my-app/about' }, { path: '/my-app/api/test' }],
+ dynamicRoutes: [
+ {
+ path: '/my-app/users/:id',
+ regex: '^/my-app/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ ],
+ });
+ });
+
+ test('should validate dynamic route regex with base path', () => {
+ const manifest = createRouteManifest({
+ basePath: '/my-app',
+ appDirPath: path.join(__dirname, 'app'),
+ });
+
+ const dynamicRoute = manifest.dynamicRoutes.find(route => route.path === '/my-app/users/:id');
+ const regex = new RegExp(dynamicRoute?.regex ?? '');
+
+ // Should match valid paths with base path
+ expect(regex.test('/my-app/users/123')).toBe(true);
+ expect(regex.test('/my-app/users/john-doe')).toBe(true);
+
+ // Should not match paths without base path
+ expect(regex.test('/users/123')).toBe(false);
+
+ // Should not match invalid paths
+ expect(regex.test('/my-app/users/')).toBe(false);
+ expect(regex.test('/my-app/users/123/extra')).toBe(false);
+ expect(regex.test('/my-app/user/123')).toBe(false);
+ });
+});