@@ -18,7 +22,8 @@ const Index = ({ shows, nodeEnv }) => {
Server-Side Rendering
- This page is server-side rendered. It fetches a random list of five TV shows from the TVmaze REST API. Refresh this page to see it change.
+ This page is server-side rendered. It fetches a random list of five TV shows from the TVmaze REST API. Refresh
+ this page to see it change.
NODE_ENV: {nodeEnv}
@@ -86,8 +91,8 @@ const Index = ({ shows, nodeEnv }) => {
Localization
- Localization (i18n) is supported! This demo uses fr
with en
as the default locale (at{' '}
- /
).
+ Localization (i18n) is supported! This demo uses fr
with en
as the default locale
+ (at /
).
The current locale is {locale}
Click on the links below to see the above text change
@@ -175,6 +180,11 @@ const Index = ({ shows, nodeEnv }) => {
Middleware
+
+
+ Middleware matching a pre-rendered dynamic route
+
+
Preview mode
Preview mode:
diff --git a/src/helpers/files.ts b/src/helpers/files.ts
index e7876aca7e..90d0eaa79e 100644
--- a/src/helpers/files.ts
+++ b/src/helpers/files.ts
@@ -58,6 +58,15 @@ export const matchesRewrite = (file: string, rewrites: Rewrites): boolean => {
return matchesRedirect(file, rewrites.beforeFiles)
}
+export const getMiddleware = async (publish: string): Promise
> => {
+ const manifestPath = join(publish, 'server', 'middleware-manifest.json')
+ if (existsSync(manifestPath)) {
+ const manifest = await readJson(manifestPath, { throws: false })
+ return manifest?.sortedMiddleware ?? []
+ }
+ return []
+}
+
// eslint-disable-next-line max-lines-per-function
export const moveStaticPages = async ({
netlifyConfig,
@@ -75,14 +84,8 @@ export const moveStaticPages = async ({
const dataDir = join('_next', 'data', buildId)
await ensureDir(dataDir)
// Load the middleware manifest so we can check if a file matches it before moving
- let middleware
- const manifestPath = join(outputDir, 'middleware-manifest.json')
- if (existsSync(manifestPath)) {
- const manifest = await readJson(manifestPath)
- if (manifest?.middleware) {
- middleware = Object.keys(manifest.middleware).map((path) => path.slice(1))
- }
- }
+ const middlewarePaths = await getMiddleware(netlifyConfig.build.publish)
+ const middleware = middlewarePaths.map((path) => path.slice(1))
const prerenderManifest: PrerenderManifest = await readJson(
join(netlifyConfig.build.publish, 'prerender-manifest.json'),
diff --git a/src/helpers/redirects.ts b/src/helpers/redirects.ts
index 2cf6e7e5b9..67090f1d3f 100644
--- a/src/helpers/redirects.ts
+++ b/src/helpers/redirects.ts
@@ -1,11 +1,15 @@
-import { NetlifyConfig } from '@netlify/build'
+/* eslint-disable max-lines */
+import type { NetlifyConfig } from '@netlify/build'
+import { yellowBright } from 'chalk'
import { readJSON } from 'fs-extra'
-import { NextConfig } from 'next'
-import { PrerenderManifest } from 'next/dist/build'
+import type { NextConfig } from 'next'
+import type { PrerenderManifest, SsgRoute } from 'next/dist/build'
+import { outdent } from 'outdent'
import { join } from 'pathe'
import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants'
+import { getMiddleware } from './files'
import { RoutesManifest } from './types'
import {
getApiRewrites,
@@ -14,9 +18,11 @@ import {
redirectsForNextRoute,
redirectsForNextRouteWithData,
routeToDataRoute,
- targetForFallback,
} from './utils'
+const matchesMiddleware = (middleware: Array, route: string): boolean =>
+ middleware.some((middlewarePath) => route.startsWith(middlewarePath))
+
const generateLocaleRedirects = ({
i18n,
basePath,
@@ -65,6 +71,150 @@ export const generateStaticRedirects = ({
}
}
+/**
+ * Routes that match middleware need to always use the SSR function
+ * This generates a rewrite for every middleware in every locale, both with and without a splat
+ */
+const generateMiddlewareRewrites = ({ basePath, middleware, i18n, buildId }) => {
+ const handlerRewrite = (from: string) => ({
+ from: `${basePath}${from}`,
+ to: HANDLER_FUNCTION_PATH,
+ status: 200,
+ })
+
+ return (
+ middleware
+ .map((route) => {
+ const unlocalized = [handlerRewrite(`${route}`), handlerRewrite(`${route}/*`)]
+ if (i18n?.locales?.length > 0) {
+ const localized = i18n.locales.map((locale) => [
+ handlerRewrite(`/${locale}${route}`),
+ handlerRewrite(`/${locale}${route}/*`),
+ handlerRewrite(`/_next/data/${buildId}/${locale}${route}/*`),
+ ])
+ // With i18n, all data routes are prefixed with the locale, but the HTML also has the unprefixed default
+ return [...unlocalized, ...localized]
+ }
+ return [...unlocalized, handlerRewrite(`/_next/data/${buildId}${route}/*`)]
+ })
+ // Flatten the array of arrays. Can't use flatMap as it might be 2 levels deep
+ .flat(2)
+ )
+}
+
+const generateStaticIsrRewrites = ({
+ staticRouteEntries,
+ basePath,
+ i18n,
+ buildId,
+ middleware,
+}: {
+ staticRouteEntries: Array<[string, SsgRoute]>
+ basePath: string
+ i18n: NextConfig['i18n']
+ buildId: string
+ middleware: Array
+}): {
+ staticRoutePaths: Set
+ staticIsrRoutesThatMatchMiddleware: Array
+ staticIsrRewrites: NetlifyConfig['redirects']
+} => {
+ const staticIsrRoutesThatMatchMiddleware: Array = []
+ const staticRoutePaths = new Set()
+ const staticIsrRewrites: NetlifyConfig['redirects'] = []
+ staticRouteEntries.forEach(([route, { initialRevalidateSeconds }]) => {
+ if (isApiRoute(route)) {
+ return
+ }
+ staticRoutePaths.add(route)
+
+ if (initialRevalidateSeconds === false) {
+ // These can be ignored, as they're static files handled by the CDN
+ return
+ }
+ // The default locale is served from the root, not the localised path
+ if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) {
+ route = route.slice(i18n.defaultLocale.length + 1)
+ staticRoutePaths.add(route)
+ if (matchesMiddleware(middleware, route)) {
+ staticIsrRoutesThatMatchMiddleware.push(route)
+ }
+ staticIsrRewrites.push(
+ ...redirectsForNextRouteWithData({
+ route,
+ dataRoute: routeToDataRoute(route, buildId, i18n.defaultLocale),
+ basePath,
+ to: ODB_FUNCTION_PATH,
+ force: true,
+ }),
+ )
+ } else if (matchesMiddleware(middleware, route)) {
+ // Routes that match middleware can't use the ODB
+ staticIsrRoutesThatMatchMiddleware.push(route)
+ } else {
+ // ISR routes use the ODB handler
+ staticIsrRewrites.push(
+ // No i18n, because the route is already localized
+ ...redirectsForNextRoute({ route, basePath, to: ODB_FUNCTION_PATH, force: true, buildId, i18n: null }),
+ )
+ }
+ })
+
+ return {
+ staticRoutePaths,
+ staticIsrRoutesThatMatchMiddleware,
+ staticIsrRewrites,
+ }
+}
+
+/**
+ * Generate rewrites for all dynamic routes
+ */
+const generateDynamicRewrites = ({
+ dynamicRoutes,
+ prerenderedDynamicRoutes,
+ middleware,
+ basePath,
+ buildId,
+ i18n,
+}: {
+ dynamicRoutes: RoutesManifest['dynamicRoutes']
+ prerenderedDynamicRoutes: PrerenderManifest['dynamicRoutes']
+ basePath: string
+ i18n: NextConfig['i18n']
+ buildId: string
+ middleware: Array
+}): {
+ dynamicRoutesThatMatchMiddleware: Array
+ dynamicRewrites: NetlifyConfig['redirects']
+} => {
+ const dynamicRewrites: NetlifyConfig['redirects'] = []
+ const dynamicRoutesThatMatchMiddleware: Array = []
+ dynamicRoutes.forEach((route) => {
+ if (isApiRoute(route.page)) {
+ return
+ }
+ if (route.page in prerenderedDynamicRoutes) {
+ if (matchesMiddleware(middleware, route.page)) {
+ dynamicRoutesThatMatchMiddleware.push(route.page)
+ } else {
+ dynamicRewrites.push(
+ ...redirectsForNextRoute({ buildId, route: route.page, basePath, to: ODB_FUNCTION_PATH, status: 200, i18n }),
+ )
+ }
+ } else {
+ // If the route isn't prerendered, it's SSR
+ dynamicRewrites.push(
+ ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: HANDLER_FUNCTION_PATH, i18n }),
+ )
+ }
+ })
+ return {
+ dynamicRoutesThatMatchMiddleware,
+ dynamicRewrites,
+ }
+}
+
export const generateRedirects = async ({
netlifyConfig,
nextConfig: { i18n, basePath, trailingSlash, appDir },
@@ -102,43 +252,26 @@ export const generateRedirects = async ({
...(await getPreviewRewrites({ basePath, appDir })),
)
- const staticRouteEntries = Object.entries(prerenderedStaticRoutes)
+ const middleware = await getMiddleware(netlifyConfig.build.publish)
- const staticRoutePaths = new Set()
+ netlifyConfig.redirects.push(...generateMiddlewareRewrites({ basePath, i18n, middleware, buildId }))
- // First add all static ISR routes
- staticRouteEntries.forEach(([route, { initialRevalidateSeconds }]) => {
- if (isApiRoute(route)) {
- return
- }
- staticRoutePaths.add(route)
+ const staticRouteEntries = Object.entries(prerenderedStaticRoutes)
- if (initialRevalidateSeconds === false) {
- // These can be ignored, as they're static files handled by the CDN
- return
- }
- // The default locale is served from the root, not the localised path
- if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) {
- route = route.slice(i18n.defaultLocale.length + 1)
- staticRoutePaths.add(route)
+ const routesThatMatchMiddleware: Array = []
- netlifyConfig.redirects.push(
- ...redirectsForNextRouteWithData({
- route,
- dataRoute: routeToDataRoute(route, buildId, i18n.defaultLocale),
- basePath,
- to: ODB_FUNCTION_PATH,
- force: true,
- }),
- )
- } else {
- // ISR routes use the ODB handler
- netlifyConfig.redirects.push(
- // No i18n, because the route is already localized
- ...redirectsForNextRoute({ route, basePath, to: ODB_FUNCTION_PATH, force: true, buildId, i18n: null }),
- )
- }
+ const { staticRoutePaths, staticIsrRewrites, staticIsrRoutesThatMatchMiddleware } = generateStaticIsrRewrites({
+ staticRouteEntries,
+ basePath,
+ i18n,
+ buildId,
+ middleware,
})
+
+ routesThatMatchMiddleware.push(...staticIsrRoutesThatMatchMiddleware)
+
+ netlifyConfig.redirects.push(...staticIsrRewrites)
+
// Add rewrites for all static SSR routes. This is Next 12+
staticRoutes?.forEach((route) => {
if (staticRoutePaths.has(route.page) || isApiRoute(route.page)) {
@@ -150,23 +283,16 @@ export const generateRedirects = async ({
)
})
// Add rewrites for all dynamic routes (both SSR and ISR)
- dynamicRoutes.forEach((route) => {
- if (isApiRoute(route.page)) {
- return
- }
- if (route.page in prerenderedDynamicRoutes) {
- const { fallback } = prerenderedDynamicRoutes[route.page]
-
- const { to, status } = targetForFallback(fallback)
-
- netlifyConfig.redirects.push(...redirectsForNextRoute({ buildId, route: route.page, basePath, to, status, i18n }))
- } else {
- // If the route isn't prerendered, it's SSR
- netlifyConfig.redirects.push(
- ...redirectsForNextRoute({ route: route.page, buildId, basePath, to: HANDLER_FUNCTION_PATH, i18n }),
- )
- }
+ const { dynamicRewrites, dynamicRoutesThatMatchMiddleware } = generateDynamicRewrites({
+ dynamicRoutes,
+ prerenderedDynamicRoutes,
+ middleware,
+ basePath,
+ buildId,
+ i18n,
})
+ netlifyConfig.redirects.push(...dynamicRewrites)
+ routesThatMatchMiddleware.push(...dynamicRoutesThatMatchMiddleware)
// Final fallback
netlifyConfig.redirects.push({
@@ -174,4 +300,19 @@ export const generateRedirects = async ({
to: HANDLER_FUNCTION_PATH,
status: 200,
})
+
+ const middlewareMatches = new Set(routesThatMatchMiddleware).size
+ if (middlewareMatches > 0) {
+ console.log(
+ yellowBright(outdent`
+ There ${
+ middlewareMatches === 1
+ ? `is one statically-generated or ISR route that matches`
+ : `are ${middlewareMatches} statically-generated or ISR routes that match`
+ } a middleware function. Matched routes will always be served from the SSR function and will not use ISR or be served from the CDN.
+ If this was not intended, ensure that your middleware only matches routes that you intend to use SSR.
+ `),
+ )
+ }
}
+/* eslint-enable max-lines */
diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts
index 27ff79f960..cb46b75ebe 100644
--- a/src/helpers/utils.ts
+++ b/src/helpers/utils.ts
@@ -2,13 +2,7 @@ import { NetlifyConfig } from '@netlify/build'
import globby from 'globby'
import { join } from 'pathe'
-import {
- OPTIONAL_CATCH_ALL_REGEX,
- CATCH_ALL_REGEX,
- DYNAMIC_PARAMETER_REGEX,
- ODB_FUNCTION_PATH,
- HANDLER_FUNCTION_PATH,
-} from '../constants'
+import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants'
import { I18n } from './types'
@@ -77,16 +71,6 @@ const netlifyRoutesForNextRoute = (route: string, buildId: string, i18n?: I18n):
export const isApiRoute = (route: string) => route.startsWith('/api/') || route === '/api'
-export const targetForFallback = (fallback: string | false) => {
- if (fallback === null || fallback === false) {
- // fallback = null mean "blocking", which uses ODB. For fallback=false then anything prerendered should 404.
- // However i18n pages may not have been prerendered, so we still need to hit the origin
- return { to: ODB_FUNCTION_PATH, status: 200 }
- }
- // fallback = true is also ODB
- return { to: ODB_FUNCTION_PATH, status: 200 }
-}
-
export const redirectsForNextRoute = ({
route,
buildId,
diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap
index 4ebd65561d..91e49adb40 100644
--- a/test/__snapshots__/index.js.snap
+++ b/test/__snapshots__/index.js.snap
@@ -23,6 +23,9 @@ exports.resolvePages = () => {
require.resolve('../../../.next/server/pages/getStaticProps/with-revalidate.js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallback/[...slug].js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallback/[id].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/[id].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallbackBlocking/[id].js')
require.resolve('../../../.next/server/pages/getStaticProps/withRevalidate/[id].js')
require.resolve('../../../.next/server/pages/getStaticProps/withRevalidate/withFallback/[id].js')
@@ -58,6 +61,9 @@ exports.resolvePages = () => {
require.resolve('../../../.next/server/pages/getStaticProps/with-revalidate.js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallback/[...slug].js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallback/[id].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/[id].js')
+ require.resolve('../../../.next/server/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js')
require.resolve('../../../.next/server/pages/getStaticProps/withFallbackBlocking/[id].js')
require.resolve('../../../.next/server/pages/getStaticProps/withRevalidate/[id].js')
require.resolve('../../../.next/server/pages/getStaticProps/withRevalidate/withFallback/[id].js')
@@ -93,6 +99,9 @@ exports.resolvePages = () => {
require.resolve('../../../web/.next/server/pages/getStaticProps/with-revalidate.js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallback/[...slug].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallback/[id].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/[id].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackBlocking/[id].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withRevalidate/[id].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withRevalidate/withFallback/[id].js')
@@ -128,6 +137,9 @@ exports.resolvePages = () => {
require.resolve('../../../web/.next/server/pages/getStaticProps/with-revalidate.js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallback/[...slug].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallback/[id].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/[id].js')
+ require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withFallbackBlocking/[id].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withRevalidate/[id].js')
require.resolve('../../../web/.next/server/pages/getStaticProps/withRevalidate/withFallback/[id].js')
@@ -441,6 +453,116 @@ Array [
"status": 200,
"to": "/.netlify/functions/___netlify-handler",
},
+ Object {
+ "from": "/getStaticProps/withFallbackAndMiddleware",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/en/getStaticProps/withFallbackAndMiddleware",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/en/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/en/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/es/getStaticProps/withFallbackAndMiddleware",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/es/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/es/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/fr/getStaticProps/withFallbackAndMiddleware",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/fr/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/fr/getStaticProps/withFallbackAndMiddleware/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/middle",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/en/middle",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/en/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/en/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/es/middle",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/es/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/es/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/fr/middle",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/fr/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
+ Object {
+ "from": "/_next/data/build-id/fr/middle/*",
+ "status": 200,
+ "to": "/.netlify/functions/___netlify-handler",
+ },
Object {
"force": true,
"from": "/_next/data/build-id/en/getStaticProps/with-revalidate.json",