diff --git a/cypress/config/static-root.json b/cypress/config/static-root.json
new file mode 100644
index 0000000000..b0d00721b7
--- /dev/null
+++ b/cypress/config/static-root.json
@@ -0,0 +1,8 @@
+{
+ "baseUrl": "http://localhost:3000",
+ "integrationFolder": "../../cypress/integration/static-root",
+ "pluginsFile": "../../cypress/plugins",
+ "screenshotsFolder": "../../cypress/screenshots",
+ "supportFile": "../../cypress/support/index.js",
+ "videoFolder": "../../cypress/videos"
+}
diff --git a/cypress/integration/static-root/i18n.spec.ts b/cypress/integration/static-root/i18n.spec.ts
new file mode 100644
index 0000000000..e6b387a7f9
--- /dev/null
+++ b/cypress/integration/static-root/i18n.spec.ts
@@ -0,0 +1,48 @@
+describe('Localization', () => {
+ it('should use sub routing to determine current locale', () => {
+ cy.visit('/')
+
+ cy.findByText('The current locale is en')
+
+ cy.visit('/fr')
+ cy.findByText('The current locale is fr')
+ })
+
+ it('should use the NEXT_LOCALE cookie to determine the default locale', () => {
+ cy.setCookie('NEXT_LOCALE', 'fr')
+ cy.visit('/')
+
+ cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`)
+ cy.findByText('The current locale is fr')
+ })
+
+ it('should use the nf_lang cookie to determine the default locale', () => {
+ cy.setCookie('nf_lang', 'fr')
+ cy.visit('/')
+
+ cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`)
+ cy.findByText('The current locale is fr')
+ })
+
+ it('should use Accept-Language to choose a locale', () => {
+ cy.visit('/', {
+ headers: {
+ 'Accept-Language': 'fr-FR,fr;q=0.5',
+ },
+ })
+ cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`)
+ cy.findByText('The current locale is fr')
+ })
+
+ it('should use the NEXT_LOCALE cookie over Accept-Language header to determine the default locale', () => {
+ cy.setCookie('NEXT_LOCALE', 'en')
+ cy.visit({
+ url: '/',
+ headers: {
+ 'Accept-Language': 'fr-FR,fr;q=0.5',
+ },
+ })
+ cy.url().should('eq', `${Cypress.config().baseUrl}/`)
+ cy.findByText('The current locale is en')
+ })
+})
diff --git a/cypress/integration/static-root/rewrites-redirects.spec.ts b/cypress/integration/static-root/rewrites-redirects.spec.ts
new file mode 100644
index 0000000000..a55e8affea
--- /dev/null
+++ b/cypress/integration/static-root/rewrites-redirects.spec.ts
@@ -0,0 +1,13 @@
+describe('Rewrites and Redirects', () => {
+ it('rewrites: points /old to /', () => {
+ // preview mode is off by default
+ cy.visit('/old/another/')
+ cy.findByText('Another page')
+ cy.url().should('eq', `${Cypress.config().baseUrl}/old/another/`)
+ })
+
+ it('redirects: redirects /redirectme to /', () => {
+ cy.visit('/redirectme')
+ cy.url().should('eq', `${Cypress.config().baseUrl}/`)
+ })
+})
diff --git a/demos/default/netlify.toml b/demos/default/netlify.toml
index 1cc78b1fc9..b465745ebb 100644
--- a/demos/default/netlify.toml
+++ b/demos/default/netlify.toml
@@ -1,7 +1,7 @@
[build]
command = "next build"
publish = ".next"
-ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../"
+ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../"
[build.environment]
# cache Cypress binary in local "node_modules" folder
diff --git a/demos/nx-next-monorepo-demo/netlify.toml b/demos/nx-next-monorepo-demo/netlify.toml
index 42d53f708d..fc7702daf0 100644
--- a/demos/nx-next-monorepo-demo/netlify.toml
+++ b/demos/nx-next-monorepo-demo/netlify.toml
@@ -1,12 +1,14 @@
[build]
- command = "npm run build"
- publish = "dist/apps/demo-monorepo/.next"
+command = "npm run build"
+publish = "dist/apps/demo-monorepo/.next"
+ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../"
+
[dev]
- command = "npm run start"
- targetPort = 4200
-
+command = "npm run start"
+targetPort = 4200
+
[[plugins]]
- package = "./local-plugin"
+package = "./local-plugin"
[build.environment]
# cache Cypress binary in local "node_modules" folder
diff --git a/demos/static-root/.eslintrc b/demos/static-root/.eslintrc
new file mode 100644
index 0000000000..abd5579b49
--- /dev/null
+++ b/demos/static-root/.eslintrc
@@ -0,0 +1,4 @@
+{
+ "extends": "next",
+ "root": true
+}
diff --git a/demos/static-root/.gitignore b/demos/static-root/.gitignore
new file mode 100644
index 0000000000..1437c53f70
--- /dev/null
+++ b/demos/static-root/.gitignore
@@ -0,0 +1,34 @@
+# 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*
+
+# local env files
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# vercel
+.vercel
diff --git a/demos/static-root/README.md b/demos/static-root/README.md
new file mode 100644
index 0000000000..b12f3e33e7
--- /dev/null
+++ b/demos/static-root/README.md
@@ -0,0 +1,34 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
+
+[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
+
+The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/demos/static-root/local-plugin/index.js b/demos/static-root/local-plugin/index.js
new file mode 100644
index 0000000000..9e852e382e
--- /dev/null
+++ b/demos/static-root/local-plugin/index.js
@@ -0,0 +1 @@
+module.exports = require('../../../lib')
diff --git a/demos/static-root/local-plugin/manifest.yml b/demos/static-root/local-plugin/manifest.yml
new file mode 100644
index 0000000000..7091f91411
--- /dev/null
+++ b/demos/static-root/local-plugin/manifest.yml
@@ -0,0 +1 @@
+name: '@netlify/plugin-nextjs-local'
diff --git a/demos/static-root/local-plugin/package-lock.json b/demos/static-root/local-plugin/package-lock.json
new file mode 100644
index 0000000000..9bd7b537cc
--- /dev/null
+++ b/demos/static-root/local-plugin/package-lock.json
@@ -0,0 +1,14 @@
+{
+ "name": "local-plugin",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "local-plugin",
+ "version": "1.0.0",
+ "hasInstallScript": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/demos/static-root/local-plugin/package.json b/demos/static-root/local-plugin/package.json
new file mode 100644
index 0000000000..d692119a5b
--- /dev/null
+++ b/demos/static-root/local-plugin/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "local-plugin",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "preinstall": "cd ../../.. && npm i"
+ },
+ "author": "",
+ "license": "ISC"
+}
diff --git a/demos/static-root/netlify.toml b/demos/static-root/netlify.toml
new file mode 100644
index 0000000000..69e5827ba4
--- /dev/null
+++ b/demos/static-root/netlify.toml
@@ -0,0 +1,24 @@
+[build]
+command = "next build"
+publish = ".next"
+ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../"
+
+[build.environment]
+# cache Cypress binary in local "node_modules" folder
+# so Netlify caches it
+CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary"
+
+[dev]
+framework = "#static"
+
+[[plugins]]
+package = "./local-plugin"
+
+[[plugins]]
+package = "@netlify/plugin-local-install-core"
+
+[[context.deploy-preview.plugins]]
+package = "netlify-plugin-cypress"
+
+[context.deploy-preview.plugins.inputs]
+configFile = "../../cypress/config/static-root.json"
diff --git a/demos/static-root/next.config.js b/demos/static-root/next.config.js
new file mode 100644
index 0000000000..59274ee24d
--- /dev/null
+++ b/demos/static-root/next.config.js
@@ -0,0 +1,46 @@
+module.exports = {
+ // Configurable site features we support:
+ // distDir: 'build',
+ generateBuildId: () => 'build-id',
+ i18n: {
+ defaultLocale: 'en',
+ locales: ['en', 'es', 'fr'],
+ },
+ async headers() {
+ return [
+ {
+ source: '/',
+ headers: [
+ {
+ key: 'x-custom-header',
+ value: 'my custom header value',
+ },
+ ],
+ },
+ ]
+ },
+ trailingSlash: true,
+ // Configurable site features _to_ support:
+ // basePath: '/docs',
+ // Rewrites allow you to map an incoming request path to a different destination path.
+ async rewrites() {
+ return {
+ beforeFiles: [
+ {
+ source: '/old/:path*',
+ destination: '/:path*',
+ },
+ ],
+ }
+ },
+ // Redirects allow you to redirect an incoming request path to a different destination path.
+ async redirects() {
+ return [
+ {
+ source: '/redirectme',
+ destination: '/',
+ permanent: true,
+ },
+ ]
+ },
+}
diff --git a/demos/static-root/pages/_app.js b/demos/static-root/pages/_app.js
new file mode 100644
index 0000000000..1e1cec9242
--- /dev/null
+++ b/demos/static-root/pages/_app.js
@@ -0,0 +1,7 @@
+import '../styles/globals.css'
+
+function MyApp({ Component, pageProps }) {
+ return
+}
+
+export default MyApp
diff --git a/demos/static-root/pages/another.js b/demos/static-root/pages/another.js
new file mode 100644
index 0000000000..e9cebb8bfd
--- /dev/null
+++ b/demos/static-root/pages/another.js
@@ -0,0 +1,22 @@
+import Head from 'next/head'
+import { useRouter } from 'next/router'
+import styles from '../styles/Home.module.css'
+
+export default function Home() {
+ const { locale } = useRouter()
+ return (
+
+
+
Create Next App
+
+
+
+
+
+ Another page
+
+
+
+
+ )
+}
diff --git a/demos/static-root/pages/api/hello.js b/demos/static-root/pages/api/hello.js
new file mode 100644
index 0000000000..9987aff4c3
--- /dev/null
+++ b/demos/static-root/pages/api/hello.js
@@ -0,0 +1,5 @@
+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
+
+export default (req, res) => {
+ res.status(200).json({ name: 'John Doe' })
+}
diff --git a/demos/static-root/pages/index.js b/demos/static-root/pages/index.js
new file mode 100644
index 0000000000..fcb38d888a
--- /dev/null
+++ b/demos/static-root/pages/index.js
@@ -0,0 +1,52 @@
+import Head from 'next/head'
+import Image from 'next/image'
+import { useRouter } from 'next/router'
+import styles from '../styles/Home.module.css'
+
+export default function Home() {
+ const { locale } = useRouter()
+ return (
+
+
+
Create Next App
+
+
+
+
+
+
+
+ The current locale is {locale}
+
+
+ Get started by editing{' '}
+ pages/index.js
+
+
+
+
+
+
+
+ )
+}
diff --git a/demos/static-root/public/favicon.ico b/demos/static-root/public/favicon.ico
new file mode 100644
index 0000000000..4965832f2c
Binary files /dev/null and b/demos/static-root/public/favicon.ico differ
diff --git a/demos/static-root/styles/Home.module.css b/demos/static-root/styles/Home.module.css
new file mode 100644
index 0000000000..35454bb748
--- /dev/null
+++ b/demos/static-root/styles/Home.module.css
@@ -0,0 +1,121 @@
+.container {
+ min-height: 100vh;
+ padding: 0 0.5rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+}
+
+.main {
+ padding: 5rem 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.footer {
+ width: 100%;
+ height: 100px;
+ border-top: 1px solid #eaeaea;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.footer a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.title a {
+ color: #0070f3;
+ text-decoration: none;
+}
+
+.title a:hover,
+.title a:focus,
+.title a:active {
+ text-decoration: underline;
+}
+
+.title {
+ margin: 0;
+ line-height: 1.15;
+ font-size: 4rem;
+}
+
+.title,
+.description {
+ text-align: center;
+}
+
+.description {
+ line-height: 1.5;
+ font-size: 1.5rem;
+}
+
+.code {
+ background: #fafafa;
+ border-radius: 5px;
+ padding: 0.75rem;
+ font-size: 1.1rem;
+ font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
+ Bitstream Vera Sans Mono, Courier New, monospace;
+}
+
+.grid {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ max-width: 800px;
+ margin-top: 3rem;
+}
+
+.card {
+ margin: 1rem;
+ padding: 1.5rem;
+ text-align: left;
+ color: inherit;
+ text-decoration: none;
+ border: 1px solid #eaeaea;
+ border-radius: 10px;
+ transition: color 0.15s ease, border-color 0.15s ease;
+ width: 45%;
+}
+
+.card:hover,
+.card:focus,
+.card:active {
+ color: #0070f3;
+ border-color: #0070f3;
+}
+
+.card h2 {
+ margin: 0 0 1rem 0;
+ font-size: 1.5rem;
+}
+
+.card p {
+ margin: 0;
+ font-size: 1.25rem;
+ line-height: 1.5;
+}
+
+.logo {
+ height: 1em;
+ margin-left: 0.5rem;
+}
+
+@media (max-width: 600px) {
+ .grid {
+ width: 100%;
+ flex-direction: column;
+ }
+}
diff --git a/demos/static-root/styles/globals.css b/demos/static-root/styles/globals.css
new file mode 100644
index 0000000000..e5e2dcc23b
--- /dev/null
+++ b/demos/static-root/styles/globals.css
@@ -0,0 +1,16 @@
+html,
+body {
+ padding: 0;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
+ Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/src/constants.ts b/src/constants.ts
index 5058fd7257..58ee77f39e 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -15,9 +15,9 @@ export const HIDDEN_PATHS = [
'/BUILD_ID',
]
-module.exports = {
- HIDDEN_PATHS,
- IMAGE_FUNCTION_NAME,
- HANDLER_FUNCTION_NAME,
- ODB_FUNCTION_NAME,
-}
+export const ODB_FUNCTION_PATH = `/.netlify/builders/${ODB_FUNCTION_NAME}`
+export const HANDLER_FUNCTION_PATH = `/.netlify/functions/${HANDLER_FUNCTION_NAME}`
+
+export const CATCH_ALL_REGEX = /\/\[\.{3}(.*)](.json)?$/
+export const OPTIONAL_CATCH_ALL_REGEX = /\/\[{2}\.{3}(.*)]{2}(.json)?$/
+export const DYNAMIC_PARAMETER_REGEX = /\/\[(.*?)]/g
diff --git a/src/helpers/config.ts b/src/helpers/config.ts
index b7c5a19a16..9438610a24 100644
--- a/src/helpers/config.ts
+++ b/src/helpers/config.ts
@@ -1,173 +1,15 @@
-/* eslint-disable max-lines */
-
-import { NetlifyConfig } from '@netlify/build'
-import { yellowBright } from 'chalk'
import { readJSON } from 'fs-extra'
-import { PrerenderManifest } from 'next/dist/build'
-import { outdent } from 'outdent'
import { join, dirname, relative } from 'pathe'
import slash from 'slash'
-import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, HIDDEN_PATHS } from '../constants'
+import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME } from '../constants'
import { RequiredServerFiles } from './requiredServerFilesType'
-const defaultFailBuild = (message: string, { error } ): never => {
+const defaultFailBuild = (message: string, { error }): never => {
throw new Error(`${message}\n${error && error.stack}`)
}
-const ODB_FUNCTION_PATH = `/.netlify/builders/${ODB_FUNCTION_NAME}`
-const HANDLER_FUNCTION_PATH = `/.netlify/functions/${HANDLER_FUNCTION_NAME}`
-
-const CATCH_ALL_REGEX = /\/\[\.{3}(.*)](.json)?$/
-const OPTIONAL_CATCH_ALL_REGEX = /\/\[{2}\.{3}(.*)]{2}(.json)?$/
-const DYNAMIC_PARAMETER_REGEX = /\/\[(.*?)]/g
-
-const getNetlifyRoutes = (nextRoute: string): Array => {
- let netlifyRoutes = [nextRoute]
-
- // If the route is an optional catch-all route, we need to add a second
- // Netlify route for the base path (when no parameters are present).
- // The file ending must be present!
- if (OPTIONAL_CATCH_ALL_REGEX.test(nextRoute)) {
- let netlifyRoute = nextRoute.replace(OPTIONAL_CATCH_ALL_REGEX, '$2')
-
- // When optional catch-all route is at top-level, the regex on line 19 will
- // create an empty string, but actually needs to be a forward slash
- if (netlifyRoute === '') netlifyRoute = '/'
-
- // When optional catch-all route is at top-level, the regex on line 19 will
- // create an incorrect route for the data route. For example, it creates
- // /_next/data/%BUILDID%.json, but NextJS looks for
- // /_next/data/%BUILDID%/index.json
- netlifyRoute = netlifyRoute.replace(/(\/_next\/data\/[^/]+).json/, '$1/index.json')
-
- // Add second route to the front of the array
- netlifyRoutes.unshift(netlifyRoute)
- }
-
- // Replace catch-all, e.g., [...slug]
- netlifyRoutes = netlifyRoutes.map((route) => route.replace(CATCH_ALL_REGEX, '/:$1/*'))
-
- // Replace optional catch-all, e.g., [[...slug]]
- netlifyRoutes = netlifyRoutes.map((route) => route.replace(OPTIONAL_CATCH_ALL_REGEX, '/*'))
-
- // Replace dynamic parameters, e.g., [id]
- netlifyRoutes = netlifyRoutes.map((route) => route.replace(DYNAMIC_PARAMETER_REGEX, '/:$1'))
-
- return netlifyRoutes
-}
-
-export const generateRedirects = async ({ netlifyConfig, basePath, i18n }: {
- netlifyConfig: NetlifyConfig,
- basePath: string,
- i18n
-}) => {
- const { dynamicRoutes, routes: staticRoutes }: PrerenderManifest = await readJSON(
- join(netlifyConfig.build.publish, 'prerender-manifest.json'),
- )
-
- netlifyConfig.redirects.push(
- ...HIDDEN_PATHS.map((path) => ({
- from: `${basePath}${path}`,
- to: '/404.html',
- status: 404,
- force: true,
- })),
- )
-
- const dataRedirects = []
- const pageRedirects = []
- const isrRedirects = []
- let hasIsr = false
-
- const dynamicRouteEntries = Object.entries(dynamicRoutes)
-
- const staticRouteEntries = Object.entries(staticRoutes)
-
- staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => {
- // Only look for revalidate as we need to rewrite these to SSR rather than ODB
- if (initialRevalidateSeconds === false) {
- // These can be ignored, as they're static files handled by the CDN
- return
- }
- if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) {
- route = route.slice(i18n.defaultLocale.length + 1)
- }
- hasIsr = true
- isrRedirects.push(...getNetlifyRoutes(dataRoute), ...getNetlifyRoutes(route))
- })
-
- dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => {
- // Add redirects if fallback is "null" (aka blocking) or true/a string
- if (fallback === false) {
- return
- }
- pageRedirects.push(...getNetlifyRoutes(route))
- dataRedirects.push(...getNetlifyRoutes(dataRoute))
- })
-
- if (i18n) {
- netlifyConfig.redirects.push({ from: `${basePath}/:locale/_next/static/*`, to: `/static/:splat`, status: 200 })
- }
-
- // This is only used in prod, so dev uses `next dev` directly
- netlifyConfig.redirects.push(
- // Static files are in `static`
- { from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 },
- // API routes always need to be served from the regular function
- {
- from: `${basePath}/api`,
- to: HANDLER_FUNCTION_PATH,
- status: 200,
- },
- {
- from: `${basePath}/api/*`,
- to: HANDLER_FUNCTION_PATH,
- status: 200,
- },
- // Preview mode gets forced to the function, to bypess pre-rendered pages
- {
- from: `${basePath}/*`,
- to: HANDLER_FUNCTION_PATH,
- status: 200,
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] },
- force: true,
- },
- // ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages
- ...isrRedirects.map((redirect) => ({
- from: `${basePath}${redirect}`,
- to: process.env.EXPERIMENTAL_ODB_TTL ? ODB_FUNCTION_PATH : HANDLER_FUNCTION_PATH,
- status: 200,
- force: true,
- })),
- // These are pages with fallback set, which need an ODB
- // Data redirects go first, to avoid conflict with splat redirects
- ...dataRedirects.map((redirect) => ({
- from: `${basePath}${redirect}`,
- to: ODB_FUNCTION_PATH,
- status: 200,
- })),
- // ...then all the other fallback pages
- ...pageRedirects.map((redirect) => ({
- from: `${basePath}${redirect}`,
- to: ODB_FUNCTION_PATH,
- status: 200,
- })),
- // Everything else is handled by the regular function
- { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 },
- )
- if (hasIsr) {
- console.log(
- yellowBright(outdent`
- You have some pages that use ISR (pages that use getStaticProps with revalidate set), which is not currently fully-supported by this plugin. Be aware that results may be unreliable.
- `),
- )
- }
-}
-
export const getNextConfig = async function getNextConfig({ publish, failBuild = defaultFailBuild }) {
try {
const { config, appDir, ignore }: RequiredServerFiles = await readJSON(join(publish, 'required-server-files.json'))
@@ -234,4 +76,3 @@ export const configureHandlerFunctions = ({ netlifyConfig, publish, ignore = []
})
})
}
-/* eslint-enable max-lines */
diff --git a/src/helpers/files.js b/src/helpers/files.js
index db9135d94d..2efd5546c5 100644
--- a/src/helpers/files.js
+++ b/src/helpers/files.js
@@ -235,6 +235,18 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => {
if (existsSync(defaultLocaleDir)) {
await copy(defaultLocaleDir, `${netlifyConfig.build.publish}/`)
}
+ const defaultLocaleIndex = join(netlifyConfig.build.publish, `${i18n.defaultLocale}.html`)
+ const indexHtml = join(netlifyConfig.build.publish, 'index.html')
+ if (existsSync(defaultLocaleIndex) && !existsSync(indexHtml)) {
+ try {
+ await copy(defaultLocaleIndex, indexHtml, { overwrite: false })
+ await copy(
+ join(netlifyConfig.build.publish, `${i18n.defaultLocale}.json`),
+ join(netlifyConfig.build.publish, 'index.json'),
+ { overwrite: false },
+ )
+ } catch {}
+ }
}
}
diff --git a/src/helpers/redirects.ts b/src/helpers/redirects.ts
new file mode 100644
index 0000000000..2e0020707d
--- /dev/null
+++ b/src/helpers/redirects.ts
@@ -0,0 +1,160 @@
+import { NetlifyConfig } from '@netlify/build'
+import { yellowBright } from 'chalk'
+import { readJSON } from 'fs-extra'
+import { NextConfig } from 'next'
+import { PrerenderManifest } from 'next/dist/build'
+import { outdent } from 'outdent'
+import { join } from 'pathe'
+
+import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants'
+
+import { netlifyRoutesForNextRoute } from './utils'
+
+const generateLocaleRedirects = ({
+ i18n,
+ basePath,
+ trailingSlash,
+}: Pick): NetlifyConfig['redirects'] => {
+ const redirects: NetlifyConfig['redirects'] = []
+ // If the cookie is set, we need to redirect at the origin
+ redirects.push({
+ from: `${basePath}${trailingSlash ? '/' : ''}`,
+ to: HANDLER_FUNCTION_PATH,
+ status: 200,
+ force: true,
+ conditions: {
+ Cookie: ['NEXT_LOCALE'],
+ },
+ })
+ i18n.locales.forEach((locale) => {
+ if (locale === i18n.defaultLocale) {
+ return
+ }
+ redirects.push({
+ from: `${basePath}/`,
+ to: `${basePath}/${locale}/`,
+ status: 301,
+ conditions: {
+ Language: [locale],
+ },
+ force: true,
+ })
+ })
+ return redirects
+}
+
+export const generateRedirects = async ({
+ netlifyConfig,
+ nextConfig: { i18n, basePath, trailingSlash },
+}: {
+ netlifyConfig: NetlifyConfig
+ nextConfig: Pick
+}) => {
+ const { dynamicRoutes, routes: staticRoutes }: PrerenderManifest = await readJSON(
+ join(netlifyConfig.build.publish, 'prerender-manifest.json'),
+ )
+
+ netlifyConfig.redirects.push(
+ ...HIDDEN_PATHS.map((path) => ({
+ from: `${basePath}${path}`,
+ to: '/404.html',
+ status: 404,
+ force: true,
+ })),
+ )
+
+ if (i18n && i18n.localeDetection !== false) {
+ netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash }))
+ }
+
+ const dataRedirects = []
+ const pageRedirects = []
+ const isrRedirects = []
+ let hasIsr = false
+
+ const dynamicRouteEntries = Object.entries(dynamicRoutes)
+
+ const staticRouteEntries = Object.entries(staticRoutes)
+
+ staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => {
+ // Only look for revalidate as we need to rewrite these to SSR rather than ODB
+ if (initialRevalidateSeconds === false) {
+ // These can be ignored, as they're static files handled by the CDN
+ return
+ }
+ if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) {
+ route = route.slice(i18n.defaultLocale.length + 1)
+ }
+ hasIsr = true
+ isrRedirects.push(...netlifyRoutesForNextRoute(dataRoute), ...netlifyRoutesForNextRoute(route))
+ })
+
+ dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => {
+ // Add redirects if fallback is "null" (aka blocking) or true/a string
+ if (fallback === false) {
+ return
+ }
+ pageRedirects.push(...netlifyRoutesForNextRoute(route))
+ dataRedirects.push(...netlifyRoutesForNextRoute(dataRoute))
+ })
+
+ if (i18n) {
+ netlifyConfig.redirects.push({ from: `${basePath}/:locale/_next/static/*`, to: `/static/:splat`, status: 200 })
+ }
+
+ // This is only used in prod, so dev uses `next dev` directly
+ netlifyConfig.redirects.push(
+ // Static files are in `static`
+ { from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 },
+ // API routes always need to be served from the regular function
+ {
+ from: `${basePath}/api`,
+ to: HANDLER_FUNCTION_PATH,
+ status: 200,
+ },
+ {
+ from: `${basePath}/api/*`,
+ to: HANDLER_FUNCTION_PATH,
+ status: 200,
+ },
+ // Preview mode gets forced to the function, to bypess pre-rendered pages
+ {
+ from: `${basePath}/*`,
+ to: HANDLER_FUNCTION_PATH,
+ status: 200,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore The conditions type is incorrect
+ conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] },
+ force: true,
+ },
+ // ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages
+ ...isrRedirects.map((redirect) => ({
+ from: `${basePath}${redirect}`,
+ to: process.env.EXPERIMENTAL_ODB_TTL ? ODB_FUNCTION_PATH : HANDLER_FUNCTION_PATH,
+ status: 200,
+ force: true,
+ })),
+ // These are pages with fallback set, which need an ODB
+ // Data redirects go first, to avoid conflict with splat redirects
+ ...dataRedirects.map((redirect) => ({
+ from: `${basePath}${redirect}`,
+ to: ODB_FUNCTION_PATH,
+ status: 200,
+ })),
+ // ...then all the other fallback pages
+ ...pageRedirects.map((redirect) => ({
+ from: `${basePath}${redirect}`,
+ to: ODB_FUNCTION_PATH,
+ status: 200,
+ })),
+ // Everything else is handled by the regular function
+ { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 },
+ )
+ if (hasIsr) {
+ console.log(
+ yellowBright(outdent`
+ You have some pages that use ISR (pages that use getStaticProps with revalidate set), which is not currently fully-supported by this plugin. Be aware that results may be unreliable.
+ `),
+ )
+ }
+}
diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts
new file mode 100644
index 0000000000..e50ff9073e
--- /dev/null
+++ b/src/helpers/utils.ts
@@ -0,0 +1,35 @@
+import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX } from '../constants'
+
+export const netlifyRoutesForNextRoute = (nextRoute: string): Array => {
+ const netlifyRoutes = [nextRoute]
+
+ // If the route is an optional catch-all route, we need to add a second
+ // Netlify route for the base path (when no parameters are present).
+ // The file ending must be present!
+ if (OPTIONAL_CATCH_ALL_REGEX.test(nextRoute)) {
+ let netlifyRoute = nextRoute.replace(OPTIONAL_CATCH_ALL_REGEX, '$2')
+
+ // create an empty string, but actually needs to be a forward slash
+ if (netlifyRoute === '') {
+ netlifyRoute = '/'
+ }
+ // When optional catch-all route is at top-level, the regex on line 19 will
+ // create an incorrect route for the data route. For example, it creates
+ // /_next/data/%BUILDID%.json, but NextJS looks for
+ // /_next/data/%BUILDID%/index.json
+ netlifyRoute = netlifyRoute.replace(/(\/_next\/data\/[^/]+).json/, '$1/index.json')
+
+ // Add second route to the front of the array
+ netlifyRoutes.unshift(netlifyRoute)
+ }
+
+ return netlifyRoutes.map((route) =>
+ route
+ // Replace catch-all, e.g., [...slug]
+ .replace(CATCH_ALL_REGEX, '/:$1/*')
+ // Replace optional catch-all, e.g., [[...slug]]
+ .replace(OPTIONAL_CATCH_ALL_REGEX, '/*')
+ // Replace dynamic parameters, e.g., [id]
+ .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'),
+ )
+}
diff --git a/src/index.js b/src/index.js
index 6d0e4b6ece..25aa9e1117 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,9 +2,10 @@ const { join, relative } = require('path')
const { ODB_FUNCTION_NAME } = require('./constants')
const { restoreCache, saveCache } = require('./helpers/cache')
-const { getNextConfig, configureHandlerFunctions, generateRedirects } = require('./helpers/config')
+const { getNextConfig, configureHandlerFunctions } = require('./helpers/config')
const { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } = require('./helpers/files')
const { generateFunctions, setupImageFunction, generatePagesResolver } = require('./helpers/functions')
+const { generateRedirects } = require('./helpers/redirects')
const {
verifyNetlifyBuildVersion,
checkNextSiteHasBuilt,
@@ -47,7 +48,10 @@ module.exports = {
checkNextSiteHasBuilt({ publish, failBuild })
- const { appDir, basePath, i18n, images, target, ignore } = await getNextConfig({ publish, failBuild })
+ const { appDir, basePath, i18n, images, target, ignore, trailingSlash } = await getNextConfig({
+ publish,
+ failBuild,
+ })
configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) })
@@ -74,8 +78,7 @@ module.exports = {
await generateRedirects({
netlifyConfig,
- basePath,
- i18n,
+ nextConfig: { basePath, i18n, trailingSlash },
})
},
diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap
index a3ea98ccb1..1fab6955b3 100644
--- a/test/__snapshots__/index.js.snap
+++ b/test/__snapshots__/index.js.snap
@@ -237,6 +237,39 @@ Array [
exports[`onBuild() writes correct redirects to netlifyConfig 1`] = `
Array [
+ Object {
+ "conditions": Object {
+ "Cookie": Array [
+ "NEXT_LOCALE",
+ ],
+ },
+ "force": true,
+ "from": "/",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "conditions": Object {
+ "Language": Array [
+ "es",
+ ],
+ },
+ "force": true,
+ "from": "/",
+ "status": 301,
+ "to": "/es/",
+ },
+ Object {
+ "conditions": Object {
+ "Language": Array [
+ "fr",
+ ],
+ },
+ "force": true,
+ "from": "/",
+ "status": 301,
+ "to": "/fr/",
+ },
Object {
"from": "/_ipx/*",
"status": 200,