diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts
index c225847ae8de0..d009914cfa8a1 100644
--- a/packages/next/build/webpack-config.ts
+++ b/packages/next/build/webpack-config.ts
@@ -1408,6 +1408,9 @@ export default async function getBaseWebpackConfig(
isEdgeServer ? 'edge' : 'nodejs'
),
}),
+ 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': JSON.stringify(
+ config.experimental.manualClientBasePath
+ ),
'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify(
config.experimental.newNextLinkBehavior
),
diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts
index a601d96c285cb..62a06a19cf09b 100644
--- a/packages/next/client/page-loader.ts
+++ b/packages/next/client/page-loader.ts
@@ -156,7 +156,8 @@ export default class PageLoader {
'.json'
)
return addBasePath(
- `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
+ `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`,
+ true
)
}
diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts
index b9221879b03e6..126dcdb9901a9 100644
--- a/packages/next/server/config-shared.ts
+++ b/packages/next/server/config-shared.ts
@@ -79,6 +79,7 @@ export interface NextJsWebpackConfig {
}
export interface ExperimentalConfig {
+ manualClientBasePath?: boolean
newNextLinkBehavior?: boolean
disablePostcssPresetEnv?: boolean
swcMinify?: boolean
diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts
index d36d38a9ff64d..d0e7be8c678f1 100644
--- a/packages/next/shared/lib/router/router.ts
+++ b/packages/next/shared/lib/router/router.ts
@@ -205,12 +205,23 @@ export function hasBasePath(path: string): boolean {
return hasPathPrefix(path, basePath)
}
-export function addBasePath(path: string): string {
+export function addBasePath(path: string, required?: boolean): string {
+ if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) {
+ if (!required) {
+ return path
+ }
+ }
// we only add the basepath on relative urls
return addPathPrefix(path, basePath)
}
export function delBasePath(path: string): string {
+ if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) {
+ if (!hasBasePath(path)) {
+ return path
+ }
+ }
+
path = path.slice(basePath.length)
if (!path.startsWith('/')) path = `/${path}`
return path
@@ -1120,7 +1131,7 @@ export default class Router implements BaseRouter {
if (process.env.__NEXT_HAS_REWRITES && as.startsWith('/')) {
const rewritesResult = resolveRewrites(
- addBasePath(addLocale(cleanedAs, nextState.locale)),
+ addBasePath(addLocale(cleanedAs, nextState.locale), true),
pages,
rewrites,
query,
@@ -1747,7 +1758,7 @@ export default class Router implements BaseRouter {
;({ __rewrites: rewrites } = await getClientBuildManifest())
const rewritesResult = resolveRewrites(
- addBasePath(addLocale(asPath, this.locale)),
+ addBasePath(addLocale(asPath, this.locale), true),
pages,
rewrites,
parsed.query,
diff --git a/test/e2e/manual-client-base-path/app/next.config.js b/test/e2e/manual-client-base-path/app/next.config.js
new file mode 100644
index 0000000000000..a67cbf980f82a
--- /dev/null
+++ b/test/e2e/manual-client-base-path/app/next.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ basePath: '/docs-proxy',
+ experimental: {
+ manualClientBasePath: true,
+ },
+}
diff --git a/test/e2e/manual-client-base-path/app/pages/another.js b/test/e2e/manual-client-base-path/app/pages/another.js
new file mode 100644
index 0000000000000..99d0b25f99cc2
--- /dev/null
+++ b/test/e2e/manual-client-base-path/app/pages/another.js
@@ -0,0 +1,50 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+
+export default function Page(props) {
+ const router = useRouter()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ return (
+ <>
+
another page
+ {JSON.stringify(props)}
+
+ {JSON.stringify(
+ mounted
+ ? {
+ basePath: router.basePath,
+ pathname: router.pathname,
+ asPath: router.asPath,
+ query: router.query,
+ }
+ : {}
+ )}
+
+
+
+ to /index
+
+
+
+
+ to /dynamic/first
+
+
+ >
+ )
+}
+
+export function getServerSideProps() {
+ return {
+ props: {
+ hello: 'world',
+ now: Date.now(),
+ },
+ }
+}
diff --git a/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js
new file mode 100644
index 0000000000000..29d34840f8f28
--- /dev/null
+++ b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js
@@ -0,0 +1,58 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+
+export default function Page(props) {
+ const router = useRouter()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ return (
+ <>
+ dynamic page
+ {JSON.stringify(props)}
+
+ {JSON.stringify(
+ mounted
+ ? {
+ basePath: router.basePath,
+ pathname: router.pathname,
+ asPath: router.asPath,
+ query: router.query,
+ }
+ : {}
+ )}
+
+
+
+ to /index
+
+
+
+
+ to /dynamic/second
+
+
+ >
+ )
+}
+
+export function getStaticPaths() {
+ return {
+ paths: ['/dynamic/first'],
+ fallback: true,
+ }
+}
+
+export function getStaticProps({ params }) {
+ return {
+ props: {
+ params,
+ hello: 'world',
+ now: Date.now(),
+ },
+ }
+}
diff --git a/test/e2e/manual-client-base-path/app/pages/index.js b/test/e2e/manual-client-base-path/app/pages/index.js
new file mode 100644
index 0000000000000..d75da658d6343
--- /dev/null
+++ b/test/e2e/manual-client-base-path/app/pages/index.js
@@ -0,0 +1,41 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+
+export default function Page(props) {
+ const router = useRouter()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ return (
+ <>
+ index page
+ {JSON.stringify(props)}
+
+ {JSON.stringify(
+ mounted
+ ? {
+ basePath: router.basePath,
+ pathname: router.pathname,
+ asPath: router.asPath,
+ query: router.query,
+ }
+ : {}
+ )}
+
+
+
+ to /another
+
+
+
+
+ to /dynamic/first
+
+
+ >
+ )
+}
diff --git a/test/e2e/manual-client-base-path/index.test.ts b/test/e2e/manual-client-base-path/index.test.ts
new file mode 100644
index 0000000000000..212c6842099f4
--- /dev/null
+++ b/test/e2e/manual-client-base-path/index.test.ts
@@ -0,0 +1,193 @@
+import { createNext, FileRef } from 'e2e-utils'
+import { NextInstance } from 'test/lib/next-modes/base'
+import httpProxy from 'http-proxy'
+import { join } from 'path'
+import http from 'http'
+import webdriver from 'next-webdriver'
+import assert from 'assert'
+import { check, waitFor } from 'next-test-utils'
+
+describe('manual-client-base-path', () => {
+ let next: NextInstance
+ let server: http.Server
+ let appPort: string
+ const basePath = '/docs-proxy'
+ const responses = new Set()
+
+ beforeAll(async () => {
+ next = await createNext({
+ files: {
+ pages: new FileRef(join(__dirname, 'app/pages')),
+ 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')),
+ },
+ dependencies: {},
+ })
+ const getProxyTarget = (req) => {
+ const destination = new URL(next.url)
+ const reqUrl = new URL(req.url, 'http://localhost')
+ // force IPv4 for testing in node 17+ as the default
+ // switched to favor IPv6 over IPv4
+ destination.hostname = '127.0.0.1'
+
+ if (req.url.startsWith(basePath)) {
+ destination.pathname = reqUrl.pathname || '/'
+ } else {
+ destination.pathname = `${basePath}${
+ reqUrl.pathname === '/' ? '' : reqUrl.pathname
+ }`
+ }
+ reqUrl.searchParams.forEach((value, key) => {
+ destination.searchParams.set(key, value)
+ })
+
+ console.log('proxying', req.url, 'to:', destination.toString())
+ return destination
+ }
+
+ server = http
+ .createServer((req, res) => {
+ responses.add(res)
+ res.on('close', () => responses.delete(res))
+
+ const destination = getProxyTarget(req)
+ const proxy = httpProxy.createProxy({
+ changeOrigin: true,
+ ignorePath: true,
+ xfwd: true,
+ proxyTimeout: 30_000,
+ target: destination.toString(),
+ })
+
+ proxy.on('error', (err) => console.error(err))
+ proxy.web(req, res)
+ })
+ .listen(0)
+
+ server.on('upgrade', (req, socket, head) => {
+ responses.add(socket)
+ socket.on('close', () => responses.delete(socket))
+
+ const destination = getProxyTarget(req)
+ const proxy = httpProxy.createProxy({
+ changeOrigin: true,
+ ignorePath: true,
+ xfwd: true,
+ proxyTimeout: 30_000,
+ target: destination.toString(),
+ })
+
+ proxy.on('error', (err) => console.error(err))
+ proxy.ws(req, socket, head)
+ })
+
+ // @ts-ignore type is incorrect
+ appPort = server.address().port
+ })
+ afterAll(async () => {
+ await next.destroy()
+ try {
+ server.close()
+ responses.forEach((res: any) => res.end?.() || res.close?.())
+ } catch (err) {
+ console.error(err)
+ }
+ })
+
+ for (const [asPath, pathname, query] of [
+ ['/'],
+ ['/another'],
+ ['/dynamic/first', '/dynamic/[slug]', { slug: 'first' }],
+ ['/dynamic/second', '/dynamic/[slug]', { slug: 'second' }],
+ ]) {
+ // eslint-disable-next-line
+ it(`should not update with basePath on mount ${asPath}`, async () => {
+ const fullAsPath = (asPath as string) + '?update=1'
+ const browser = await webdriver(appPort, fullAsPath)
+ await browser.eval('window.beforeNav = 1')
+
+ expect(await browser.eval('window.location.pathname')).toBe(asPath)
+ expect(await browser.eval('window.location.search')).toBe('?update=1')
+
+ await check(async () => {
+ assert.deepEqual(
+ JSON.parse(await browser.elementByCss('#router').text()),
+ {
+ asPath: fullAsPath,
+ pathname: pathname || asPath,
+ query: {
+ update: '1',
+ ...((query as any) || {}),
+ },
+ basePath,
+ }
+ )
+ return 'success'
+ }, 'success')
+
+ await waitFor(5 * 1000)
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+ }
+
+ it('should navigate correctly from index', async () => {
+ const browser = await webdriver(appPort, '/')
+ await browser.eval('window.beforeNav = 1')
+
+ await browser.elementByCss('#to-another').click()
+ await check(() => browser.elementByCss('#page').text(), 'another page')
+ expect(await browser.eval('window.location.pathname')).toBe('/another')
+
+ await browser.back()
+ await check(() => browser.elementByCss('#page').text(), 'index page')
+ expect(await browser.eval('window.location.pathname')).toBe('/')
+
+ await browser.forward()
+ await check(() => browser.elementByCss('#page').text(), 'another page')
+ expect(await browser.eval('window.location.pathname')).toBe('/another')
+
+ await browser.back()
+ await check(() => browser.elementByCss('#page').text(), 'index page')
+ expect(await browser.eval('window.location.pathname')).toBe('/')
+
+ await browser.elementByCss('#to-dynamic').click()
+ await check(() => browser.elementByCss('#page').text(), 'dynamic page')
+ expect(await browser.eval('window.location.pathname')).toBe(
+ '/dynamic/first'
+ )
+
+ await browser.back()
+ await check(() => browser.elementByCss('#page').text(), 'index page')
+ expect(await browser.eval('window.location.pathname')).toBe('/')
+
+ await browser.forward()
+ await check(() => browser.elementByCss('#page').text(), 'dynamic page')
+ expect(await browser.eval('window.location.pathname')).toBe(
+ '/dynamic/first'
+ )
+
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should navigate correctly from another', async () => {
+ const browser = await webdriver(appPort, '/another')
+ await browser.eval('window.beforeNav = 1')
+
+ await browser.elementByCss('#to-index').click()
+ await check(() => browser.elementByCss('#page').text(), 'index page')
+ expect(await browser.eval('window.location.pathname')).toBe('/')
+
+ await browser.elementByCss('#to-dynamic').click()
+ await check(() => browser.elementByCss('#page').text(), 'dynamic page')
+ expect(await browser.eval('window.location.pathname')).toBe(
+ '/dynamic/first'
+ )
+
+ await browser.elementByCss('#to-dynamic').click()
+ await check(
+ () => browser.eval('window.location.pathname'),
+ '/dynamic/second'
+ )
+
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+})