Skip to content

Commit

Permalink
Normalize trailing slashes (#13333)
Browse files Browse the repository at this point in the history
Co-authored-by: Joe Haddad <[email protected]>
Co-authored-by: Tim Neutkens <[email protected]>
  • Loading branch information
3 people authored Jun 23, 2020
1 parent b90fa0a commit 2142b76
Show file tree
Hide file tree
Showing 25 changed files with 536 additions and 23 deletions.
31 changes: 31 additions & 0 deletions docs/api-reference/next.config.js/trailing-slash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
description: Configure Next.js pages to resolve with or without a trailing slash.
---

# Trailing Slash

> **Warning**: This feature is **experimental and may not work as expected**.
> You must enable the `trailingSlash` experimental option to try it.
By default Next.js will redirect urls with trailing slashes to their counterpart without a trailing slash. For example `/about/` will redirect to `/about`. You can configure this behavior to act the opposite way, where urls without trailing slashes are redirected to their counterparts with trailing slashes.

Open `next.config.js` and add the `trailingSlash` config:

```js
module.exports = {
experimental: {
trailingSlash: true,
},
}
```

With this option set, urls like `/about` will redirect to `/about/`.

## Related

<div class="card">
<a href="/docs/api-reference/next.config.js/introduction.md">
<b>Introduction to next.config.js:</b>
<small>Learn more about the configuration file used by Next.js.</small>
</a>
</div>
4 changes: 4 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@
"title": "exportPathMap",
"path": "/docs/api-reference/next.config.js/exportPathMap.md"
},
{
"title": "Trailing Slash",
"path": "/docs/api-reference/next.config.js/trailing-slash.md"
},
{
"title": "React Strict Mode",
"path": "/docs/api-reference/next.config.js/react-strict-mode.md"
Expand Down
3 changes: 3 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,9 @@ export default async function getBaseWebpackConfig(
'process.env.__NEXT_DIST_DIR': JSON.stringify(distDir),
}
: {}),
'process.env.__NEXT_TRAILING_SLASH': JSON.stringify(
config.experimental.trailingSlash
),
'process.env.__NEXT_EXPORT_TRAILING_SLASH': JSON.stringify(
config.exportTrailingSlash
),
Expand Down
16 changes: 15 additions & 1 deletion packages/next/client/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../next-server/lib/utils'
import Router from './router'
import { addBasePath } from '../next-server/lib/router/router'
import { normalizeTrailingSlash } from '../next-server/lib/router/normalize-trailing-slash'

function isLocal(href: string): boolean {
const url = parse(href, false, true)
Expand Down Expand Up @@ -40,8 +41,21 @@ function memoizedFormatUrl(formatFunc: (href: Url, as?: Url) => FormatResult) {
}
}

function formatTrailingSlash(url: UrlObject): UrlObject {
return Object.assign({}, url, {
pathname:
url.pathname &&
normalizeTrailingSlash(url.pathname, !!process.env.__NEXT_TRAILING_SLASH),
})
}

function formatUrl(url: Url): string {
return url && typeof url === 'object' ? formatWithValidation(url) : url
return (
url &&
formatWithValidation(
formatTrailingSlash(typeof url === 'object' ? url : parse(url))
)
)
}

export type LinkProps = {
Expand Down
14 changes: 14 additions & 0 deletions packages/next/lib/load-custom-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,20 @@ export default async function loadCustomRoutes(
loadRedirects(config),
])

redirects.unshift(
config.experimental.trailingSlash
? {
source: '/:path+',
destination: '/:path+/',
permanent: true,
}
: {
source: '/:path+/',
destination: '/:path+',
permanent: true,
}
)

return {
headers,
rewrites,
Expand Down
12 changes: 12 additions & 0 deletions packages/next/next-server/lib/router/normalize-trailing-slash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function normalizeTrailingSlash(
path: string,
requireSlash?: boolean
): string {
if (path === '/') {
return path
} else if (path.endsWith('/')) {
return requireSlash ? path : path.slice(0, -1)
} else {
return requireSlash ? path + '/' : path
}
}
11 changes: 9 additions & 2 deletions packages/next/next-server/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { isDynamicRoute } from './utils/is-dynamic'
import { getRouteMatcher } from './utils/route-matcher'
import { getRouteRegex } from './utils/route-regex'
import { normalizeTrailingSlash } from './normalize-trailing-slash'
import getAssetPathFromRoute from './utils/get-asset-path-from-route'

const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
Expand Down Expand Up @@ -43,8 +44,14 @@ function prepareUrlAs(url: Url, as: Url) {
url = typeof url === 'object' ? formatWithValidation(url) : url
as = typeof as === 'object' ? formatWithValidation(as) : as

url = addBasePath(url)
as = as ? addBasePath(as) : as
url = addBasePath(
normalizeTrailingSlash(url, !!process.env.__NEXT_TRAILING_SLASH)
)
as = as
? addBasePath(
normalizeTrailingSlash(as, !!process.env.__NEXT_TRAILING_SLASH)
)
: as

return {
url,
Expand Down
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const defaultConfig: { [key: string]: any } = {
exportTrailingSlash: false,
sassOptions: {},
experimental: {
trailingSlash: false,
cpus: Math.max(
1,
(Number(process.env.CIRCLE_NODE_TOTAL) ||
Expand Down
6 changes: 5 additions & 1 deletion packages/next/next-server/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { compile as compilePathToRegex } from 'next/dist/compiled/path-to-regexp
import { loadEnvConfig } from '../../lib/load-env-config'
import './node-polyfill-fetch'
import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin'
import { normalizeTrailingSlash } from '../lib/router/normalize-trailing-slash'
import getRouteFromAssetPath from '../lib/router/utils/get-route-from-asset-path'

const getCustomRouteMatcher = pathMatch(true)
Expand Down Expand Up @@ -583,11 +584,14 @@ export default class Server {
type: 'route',
name: 'Catchall render',
fn: async (req, res, params, parsedUrl) => {
const { pathname, query } = parsedUrl
let { pathname, query } = parsedUrl
if (!pathname) {
throw new Error('pathname is undefined')
}

// next.js core assumes page path without trailing slash
pathname = normalizeTrailingSlash(pathname, false)

if (params?.path?.[0] === 'api') {
const handled = await this.handleApiRequest(
req as NextApiRequest,
Expand Down
7 changes: 7 additions & 0 deletions test/integration/amphtml-ssg/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ const runTests = (isDev = false) => {
expect($('#hello').text()).toContain('hello')
})

it('should load dynamic hybrid SSG/AMP page with trailing slash', async () => {
const html = await renderViaHTTP(appPort, '/blog/post-1/')
const $ = cheerio.load(html)
expect($('#use-amp').text()).toContain('no')
expect($('#hello').text()).toContain('hello')
})

it('should load dynamic hybrid SSG/AMP page with query', async () => {
const html = await renderViaHTTP(appPort, '/blog/post-1?amp=1')
const $ = cheerio.load(html)
Expand Down
13 changes: 10 additions & 3 deletions test/integration/api-catch-all/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,19 @@ function runTests() {
expect(data).toEqual({ slug: ['1'] })
})

it('should 404 when catch-all with index and trailing slash', async () => {
it('should return redirect when catch-all with index and trailing slash', async () => {
const res = await fetchViaHTTP(appPort, '/api/users/', null, {
redirect: 'manual',
})
expect(res.status).toBe(308)
})

it('should return data when catch-all with index and trailing slash', async () => {
const data = await fetchViaHTTP(appPort, '/api/users/', null, {}).then(
(res) => res.status
(res) => res.ok && res.json()
)

expect(data).toEqual(404)
expect(data).toEqual({})
})

it('should return data when catch-all with index and no trailing slash', async () => {
Expand Down
8 changes: 3 additions & 5 deletions test/integration/client-navigation/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1002,12 +1002,10 @@ describe('Client Navigation', () => {
await browser.close()
})

it('should 404 for <page>/', async () => {
it('should not 404 for <page>/', async () => {
const browser = await webdriver(context.appPort, '/nav/about/')
expect(await browser.elementByCss('h1').text()).toBe('404')
expect(await browser.elementByCss('h2').text()).toBe(
'This page could not be found.'
)
const text = await browser.elementByCss('p').text()
expect(text).toBe('This is the about page.')
await browser.close()
})

Expand Down
5 changes: 2 additions & 3 deletions test/integration/client-navigation/test/rendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,9 @@ export default function (render, fetch) {
expect($('h2').text()).toBe('This page could not be found.')
})

it('should 404 for <page>/', async () => {
it('should not 404 for <page>/', async () => {
const $ = await get$('/nav/about/')
expect($('h1').text()).toBe('404')
expect($('h2').text()).toBe('This page could not be found.')
expect($('.nav-about p').text()).toBe('This is the about page.')
})

it('should should not contain a page script in a 404 page', async () => {
Expand Down
8 changes: 8 additions & 0 deletions test/integration/custom-routes/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,14 @@ const runTests = (isDev = false) => {
pages404: true,
basePath: '',
redirects: [
{
destination: '/:path+',
regex: normalizeRegEx(
'^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$'
),
source: '/:path+/',
statusCode: 308,
},
{
destination: '/:lang/about',
regex: normalizeRegEx(
Expand Down
2 changes: 1 addition & 1 deletion test/integration/dynamic-routing/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ function runTests(dev) {
basePath: '',
headers: [],
rewrites: [],
redirects: [],
redirects: expect.arrayContaining([]),
dataRoutes: [
{
namedDataRouteRegex: `^/_next/data/${escapeRegex(
Expand Down
21 changes: 17 additions & 4 deletions test/integration/serverless-trace/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
findPort,
nextBuild,
nextStart,
fetchViaHTTP,
renderViaHTTP,
fetchViaHTTP,
readNextBuildClientPageFile,
getPageFileFromPagesManifest,
} from 'next-test-utils'
Expand Down Expand Up @@ -166,9 +166,22 @@ describe('Serverless Trace', () => {
expect(param).toBe('val')
})

it('should 404 on API request with trailing slash', async () => {
const res = await fetchViaHTTP(appPort, '/api/hello/')
expect(res.status).toBe(404)
it('should reply with redirect on API request with trailing slash', async () => {
const res = await fetchViaHTTP(
appPort,
'/api/hello/',
{},
{ redirect: 'manual' }
)
expect(res.status).toBe(308)
expect(res.headers.get('location')).toBe(
`http://localhost:${appPort}/api/hello`
)
})

it('should reply on API request with trailing slassh successfully', async () => {
const content = await renderViaHTTP(appPort, '/api/hello/')
expect(content).toMatch(/hello world/)
})

describe('With basic usage', () => {
Expand Down
19 changes: 16 additions & 3 deletions test/integration/serverless/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,22 @@ describe('Serverless', () => {
expect(param).toBe('val')
})

it('should 404 on API request with trailing slash', async () => {
const res = await fetchViaHTTP(appPort, '/api/hello/')
expect(res.status).toBe(404)
it('should reply with redirect on API request with trailing slash', async () => {
const res = await fetchViaHTTP(
appPort,
'/api/hello/',
{},
{ redirect: 'manual' }
)
expect(res.status).toBe(308)
expect(res.headers.get('location')).toBe(
`http://localhost:${appPort}/api/hello`
)
})

it('should reply on API request with trailing slash successfully', async () => {
const content = await renderViaHTTP(appPort, '/api/hello/')
expect(content).toMatch(/hello world/)
})

it('should have the correct query string for a dynamic route', async () => {
Expand Down
5 changes: 5 additions & 0 deletions test/integration/trailing-slashes/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
// <placeholder>
},
}
3 changes: 3 additions & 0 deletions test/integration/trailing-slashes/pages/404.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function NotFound() {
return <div id="page-404">404</div>
}
17 changes: 17 additions & 0 deletions test/integration/trailing-slashes/pages/about.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'

export default function Page() {
const [isMounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const router = useRouter()
return (
<div>
{isMounted ? <div id="hydration-marker" /> : null}
<div id="page-marker">/about.js</div>
<div id="router-pathname">{router.pathname}</div>
</div>
)
}
17 changes: 17 additions & 0 deletions test/integration/trailing-slashes/pages/catch-all/[...slug].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'

export default function Page() {
const [isMounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const router = useRouter()
return (
<div>
{isMounted ? <div id="hydration-marker" /> : null}
<div id="page-marker">/catch-all/[...slug].js</div>
<div id="router-pathname">{router.pathname}</div>
</div>
)
}
17 changes: 17 additions & 0 deletions test/integration/trailing-slashes/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'

export default function Page() {
const [isMounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const router = useRouter()
return (
<div>
{isMounted ? <div id="hydration-marker" /> : null}
<div id="page-marker">/index.js</div>
<div id="router-pathname">{router.pathname}</div>
</div>
)
}
Loading

0 comments on commit 2142b76

Please sign in to comment.