diff --git a/demos/default/local-plugin/package-lock.json b/demos/default/local-plugin/package-lock.json index 9bd7b537cc..11a2e6611e 100644 --- a/demos/default/local-plugin/package-lock.json +++ b/demos/default/local-plugin/package-lock.json @@ -1,14 +1,5 @@ { "name": "local-plugin", "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "local-plugin", - "version": "1.0.0", - "hasInstallScript": true, - "license": "ISC" - } - } + "lockfileVersion": 1 } diff --git a/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js new file mode 100644 index 0000000000..5bcb1abc6b --- /dev/null +++ b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[...slug].js @@ -0,0 +1,57 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +const Show = ({ show }) => { + const router = useRouter() + + // This is never shown on Netlify. We just need it for NextJS to be happy, + // because NextJS will render a fallback HTML page. + if (router.isFallback) { + return
Loading...
+ } + + return ( +
+

+ Check the network panel for the header x-middleware-date to ensure that it is running +

+ +
+ +

Show #{show.id}

+

{show.name}

+ +
+ + + Go back home + +
+ ) +} + +export async function getStaticPaths() { + // Set the paths we want to pre-render + const paths = [{ params: { slug: ['my', 'path', '1'] } }, { params: { slug: ['my', 'path', '2'] } }] + + // We'll pre-render these paths at build time. + // { fallback: true } means other routes will be rendered at runtime. + return { paths, fallback: true } +} + +export async function getStaticProps({ params }) { + // The ID to render + const { slug } = params + const id = slug[slug.length - 1] + + const res = await fetch(`https://api.tvmaze.com/shows/${id}`) + const data = await res.json() + + return { + props: { + show: data, + }, + } +} + +export default Show diff --git a/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[id].js b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[id].js new file mode 100644 index 0000000000..a1e1747b77 --- /dev/null +++ b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/[id].js @@ -0,0 +1,55 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +const Show = ({ show }) => { + const router = useRouter() + + // This is never shown on Netlify. We just need it for NextJS to be happy, + // because NextJS will render a fallback HTML page. + if (router.isFallback) { + return
Loading...
+ } + + return ( +
+

+ Check the network panel for the header x-middleware-date to ensure that it is running +

+
+ +

Show #{show.id}

+

{show.name}

+ +
+ + + Go back home + +
+ ) +} + +export async function getStaticPaths() { + // Set the paths we want to pre-render + const paths = [{ params: { id: '3' } }, { params: { id: '4' } }] + + // We'll pre-render these paths at build time. + // { fallback: true } means other routes will be rendered at runtime. + return { paths, fallback: true } +} + +export async function getStaticProps({ params }) { + // The ID to render + const { id } = params + + const res = await fetch(`https://api.tvmaze.com/shows/${id}`) + const data = await res.json() + + return { + props: { + show: data, + }, + } +} + +export default Show diff --git a/demos/default/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js new file mode 100644 index 0000000000..1142194f07 --- /dev/null +++ b/demos/default/pages/getStaticProps/withFallbackAndMiddleware/_middleware.js @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' + +export function middleware() { + const res = NextResponse.next() + res.headers.set('x-middleware-date', new Date().toISOString()) + return res +} diff --git a/demos/default/pages/index.js b/demos/default/pages/index.js index 2003c8f07e..a130accf60 100644 --- a/demos/default/pages/index.js +++ b/demos/default/pages/index.js @@ -8,7 +8,11 @@ const Index = ({ shows, nodeEnv }) => { return (
- NextJS on Netlify Banner + NextJS on Netlify Banner
@@ -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",