diff --git a/errors/manifest.json b/errors/manifest.json
index 07d1d8191928c..d73a3e72c68b0 100644
--- a/errors/manifest.json
+++ b/errors/manifest.json
@@ -778,6 +778,10 @@
           "title": "context-in-server-component",
           "path": "/errors/context-in-server-component.md"
         },
+        {
+          "title": "next-response-next-in-app-route-handler",
+          "path": "/errors/next-response-next-in-app-route-handler.md"
+        },
         {
           "title": "react-client-hook-in-server-component",
           "path": "/errors/react-client-hook-in-server-component.md"
diff --git a/errors/next-response-next-in-app-route-handler.md b/errors/next-response-next-in-app-route-handler.md
new file mode 100644
index 0000000000000..9f140bc047dac
--- /dev/null
+++ b/errors/next-response-next-in-app-route-handler.md
@@ -0,0 +1,14 @@
+# `NextResponse.next()` used in a App Route Handler
+
+#### Why This Error Occurred
+
+App Route Handler's do not currently support using the `NextResponse.next()` method to forward to the next middleware because the handler is considered the endpoint to the middleware chain. Handlers must always return a `Response` object instead.
+
+#### Possible Ways to Fix It
+
+Remove the `NextResponse.next()` and replace it with a correct response handler.
+
+### Useful Links
+
+- [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
+- [`NextResponse`](https://nextjs.org/docs/api-reference/next/server#nextresponse)
diff --git a/examples/app-dir-mdx/mdx-components.tsx b/examples/app-dir-mdx/mdx-components.tsx
index b980a0bcd8348..5e157b1df7236 100644
--- a/examples/app-dir-mdx/mdx-components.tsx
+++ b/examples/app-dir-mdx/mdx-components.tsx
@@ -1,7 +1,7 @@
+import type { MDXComponents } from 'mdx/types'
+
 // This file is required to use MDX in `app` directory.
-export function useMDXComponents(components: {
-  [component: string]: React.ComponentType
-}) {
+export function useMDXComponents(components: MDXComponents): MDXComponents {
   return {
     // Allows customizing built-in components, e.g. to add styling.
     // h1: ({ children }) => <h1 style={{ fontSize: "100px" }}>{children}</h1>,
diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts
index b436ff711e620..7db3fb215abf7 100644
--- a/packages/next/src/build/entries.ts
+++ b/packages/next/src/build/entries.ts
@@ -215,7 +215,7 @@ export function getAppEntry(opts: {
   name: string
   pagePath: string
   appDir: string
-  appPaths: string[] | null
+  appPaths: ReadonlyArray<string> | null
   pageExtensions: string[]
   assetPrefix: string
   isDev?: boolean
@@ -310,7 +310,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) {
   let appPathsPerRoute: Record<string, string[]> = {}
   if (appDir && appPaths) {
     for (const pathname in appPaths) {
-      const normalizedPath = normalizeAppPath(pathname) || '/'
+      const normalizedPath = normalizeAppPath(pathname)
       if (!appPathsPerRoute[normalizedPath]) {
         appPathsPerRoute[normalizedPath] = []
       }
@@ -403,8 +403,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) {
         },
         onServer: () => {
           if (pagesType === 'app' && appDir) {
-            const matchedAppPaths =
-              appPathsPerRoute[normalizeAppPath(page) || '/']
+            const matchedAppPaths = appPathsPerRoute[normalizeAppPath(page)]
             server[serverBundlePath] = getAppEntry({
               name: serverBundlePath,
               pagePath: mappings[page],
@@ -420,8 +419,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) {
         onEdgeServer: () => {
           let appDirLoader: string = ''
           if (pagesType === 'app') {
-            const matchedAppPaths =
-              appPathsPerRoute[normalizeAppPath(page) || '/']
+            const matchedAppPaths = appPathsPerRoute[normalizeAppPath(page)]
             appDirLoader = getAppEntry({
               name: serverBundlePath,
               pagePath: mappings[page],
diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts
index 66506ab13aecd..28a69f68530d4 100644
--- a/packages/next/src/build/index.ts
+++ b/packages/next/src/build/index.ts
@@ -49,7 +49,7 @@ import {
   PAGES_MANIFEST,
   PHASE_PRODUCTION_BUILD,
   PRERENDER_MANIFEST,
-  FLIGHT_MANIFEST,
+  CLIENT_REFERENCE_MANIFEST,
   REACT_LOADABLE_MANIFEST,
   ROUTES_MANIFEST,
   SERVER_DIRECTORY,
@@ -501,7 +501,9 @@ export default async function build(
           .traceAsyncFn(() =>
             recursiveReadDir(
               appDir,
-              new RegExp(`^page\\.(?:${config.pageExtensions.join('|')})$`)
+              new RegExp(
+                `^(page|route)\\.(?:${config.pageExtensions.join('|')})$`
+              )
             )
           )
       }
@@ -578,7 +580,7 @@ export default async function build(
       if (mappedAppPages) {
         denormalizedAppPages = Object.keys(mappedAppPages)
         for (const appKey of denormalizedAppPages) {
-          const normalizedAppPageKey = normalizeAppPath(appKey) || '/'
+          const normalizedAppPageKey = normalizeAppPath(appKey)
           const pagePath = mappedPages[normalizedAppPageKey]
           if (pagePath) {
             const appPath = mappedAppPages[appKey]
@@ -904,8 +906,14 @@ export default async function build(
                     : []),
                   path.join(SERVER_DIRECTORY, APP_PATHS_MANIFEST),
                   APP_BUILD_MANIFEST,
-                  path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.js'),
-                  path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.json'),
+                  path.join(
+                    SERVER_DIRECTORY,
+                    CLIENT_REFERENCE_MANIFEST + '.js'
+                  ),
+                  path.join(
+                    SERVER_DIRECTORY,
+                    CLIENT_REFERENCE_MANIFEST + '.json'
+                  ),
                   path.join(
                     SERVER_DIRECTORY,
                     FLIGHT_SERVER_CSS_MANIFEST + '.js'
@@ -1091,7 +1099,7 @@ export default async function build(
         )
 
         Object.keys(appPathsManifest).forEach((entry) => {
-          appPathRoutes[entry] = normalizeAppPath(entry) || '/'
+          appPathRoutes[entry] = normalizeAppPath(entry)
         })
         await promises.writeFile(
           path.join(distDir, APP_PATH_ROUTES_MANIFEST),
@@ -1382,7 +1390,9 @@ export default async function build(
                         if (
                           (!isDynamicRoute(page) ||
                             !workerResult.prerenderRoutes?.length) &&
-                          workerResult.appConfig?.revalidate !== 0
+                          workerResult.appConfig?.revalidate !== 0 &&
+                          // TODO-APP: (wyattjoh) this may be where we can enable prerendering for app handlers
+                          originalAppPath.endsWith('/page')
                         ) {
                           appStaticPaths.set(originalAppPath, [page])
                           appStaticPathsEncoded.set(originalAppPath, [page])
diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts
index 5c18116f7a84c..79fcfd301bff2 100644
--- a/packages/next/src/build/webpack/loaders/next-app-loader.ts
+++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts
@@ -12,6 +12,7 @@ import { APP_DIR_ALIAS } from '../../../lib/constants'
 import { buildMetadata, discoverStaticMetadataFiles } from './metadata/discover'
 
 const isNotResolvedError = (err: any) => err.message.includes("Can't resolve")
+import { isAppRouteRoute } from '../../../lib/is-app-route-route'
 
 const FILE_TYPES = {
   layout: 'layout',
@@ -25,6 +26,10 @@ const FILE_TYPES = {
 const GLOBAL_ERROR_FILE_TYPE = 'global-error'
 const PAGE_SEGMENT = 'page$'
 
+type PathResolver = (
+  pathname: string,
+  resolveDir?: boolean
+) => Promise<string | undefined>
 export type ComponentsType = {
   readonly [componentKey in ValueOf<typeof FILE_TYPES>]?: ModuleReference
 } & {
@@ -33,6 +38,35 @@ export type ComponentsType = {
   readonly metadata?: CollectedMetadata
 }
 
+async function createAppRouteCode({
+  pagePath,
+  resolver,
+}: {
+  pagePath: string
+  resolver: PathResolver
+}): Promise<string> {
+  // Split based on any specific path separators (both `/` and `\`)...
+  const splittedPath = pagePath.split(/[\\/]/)
+  // Then join all but the last part with the same separator, `/`...
+  const segmentPath = splittedPath.slice(0, -1).join('/')
+  // Then add the `/route` suffix...
+  const matchedPagePath = `${segmentPath}/route`
+  // This, when used with the resolver will give us the pathname to the built
+  // route handler file.
+  const resolvedPagePath = await resolver(matchedPagePath)
+
+  // TODO: verify if other methods need to be injected
+  // TODO: validate that the handler exports at least one of the supported methods
+
+  return `
+    import 'next/dist/server/node-polyfill-headers'
+    
+    export * as handlers from ${JSON.stringify(resolvedPagePath)}
+
+    export { requestAsyncStorage } from 'next/dist/client/components/request-async-storage'
+  `
+}
+
 async function createTreeCodeFromPath(
   pagePath: string,
   {
@@ -279,8 +313,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
 
     return Object.entries(matched)
   }
-
-  const resolver = async (pathname: string, resolveDir?: boolean) => {
+  const resolver: PathResolver = async (pathname, resolveDir) => {
     if (resolveDir) {
       return createAbsolutePath(appDir, pathname)
     }
@@ -302,6 +335,10 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
     }
   }
 
+  if (isAppRouteRoute(name)) {
+    return createAppRouteCode({ pagePath, resolver })
+  }
+
   const {
     treeCode,
     pages: pageListCode,
diff --git a/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts
index e28abe5b935a2..7abb4f735b309 100644
--- a/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts
+++ b/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts
@@ -1,7 +1,7 @@
 import type { IncomingMessage, ServerResponse } from 'http'
 import type { Rewrite } from '../../../../lib/load-custom-routes'
 import type { BuildManifest } from '../../../../server/get-page-files'
-import type { RouteMatch } from '../../../../shared/lib/router/utils/route-matcher'
+import type { RouteMatchFn } from '../../../../shared/lib/router/utils/route-matcher'
 import type { NextConfig } from '../../../../server/config'
 import type {
   GetServerSideProps,
@@ -144,7 +144,7 @@ export function getUtils({
   trailingSlash?: boolean
 }) {
   let defaultRouteRegex: ReturnType<typeof getNamedRouteRegex> | undefined
-  let dynamicRouteMatcher: RouteMatch | undefined
+  let dynamicRouteMatcher: RouteMatchFn | undefined
   let defaultRouteMatches: ParsedUrlQuery | undefined
 
   if (pageIsDynamic) {
diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
index 16160a11f4f2a..805f6fa9bc77e 100644
--- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
+++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
@@ -19,7 +19,7 @@ import {
   APP_CLIENT_INTERNALS,
   COMPILER_NAMES,
   EDGE_RUNTIME_WEBPACK,
-  ACTIONS_MANIFEST,
+  SERVER_REFERENCE_MANIFEST,
   FLIGHT_SERVER_CSS_MANIFEST,
 } from '../../../shared/lib/constants'
 import { ASYNC_CLIENT_MODULES } from './flight-manifest-plugin'
@@ -756,9 +756,8 @@ export class FlightClientEntryPlugin {
       }
     }
 
-    const file = ACTIONS_MANIFEST
     const json = JSON.stringify(serverActions, null, this.dev ? 2 : undefined)
-    assets[file + '.json'] = new sources.RawSource(
+    assets[SERVER_REFERENCE_MANIFEST + '.json'] = new sources.RawSource(
       json
     ) as unknown as webpack.sources.RawSource
   }
diff --git a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
index 26192a312a9d0..44e9c7af02357 100644
--- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
+++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
@@ -6,7 +6,7 @@
  */
 
 import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
-import { FLIGHT_MANIFEST } from '../../../shared/lib/constants'
+import { CLIENT_REFERENCE_MANIFEST } from '../../../shared/lib/constants'
 import { relative, sep } from 'path'
 import { isClientComponentModule, regexCSS } from '../loaders/utils'
 
@@ -369,7 +369,7 @@ export class FlightManifestPlugin {
       manifest.__entry_css_files__ = entryCSSFiles
     })
 
-    const file = 'server/' + FLIGHT_MANIFEST
+    const file = 'server/' + CLIENT_REFERENCE_MANIFEST
     const json = JSON.stringify(manifest, null, this.dev ? 2 : undefined)
 
     ASYNC_CLIENT_MODULES.clear()
diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts
index 0b957799c0069..7f23b41d4cdfd 100644
--- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts
+++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts
@@ -13,7 +13,7 @@ import {
   EDGE_RUNTIME_WEBPACK,
   EDGE_UNSUPPORTED_NODE_APIS,
   MIDDLEWARE_BUILD_MANIFEST,
-  FLIGHT_MANIFEST,
+  CLIENT_REFERENCE_MANIFEST,
   MIDDLEWARE_MANIFEST,
   MIDDLEWARE_REACT_LOADABLE_MANIFEST,
   NEXT_CLIENT_SSR_ENTRY_SUFFIX,
@@ -95,7 +95,7 @@ function getEntryFiles(
   const files: string[] = []
   if (meta.edgeSSR) {
     if (meta.edgeSSR.isServerComponent) {
-      files.push(`server/${FLIGHT_MANIFEST}.js`)
+      files.push(`server/${CLIENT_REFERENCE_MANIFEST}.js`)
       files.push(`server/${FLIGHT_SERVER_CSS_MANIFEST}.js`)
       if (opts.sriEnabled) {
         files.push(`server/${SUBRESOURCE_INTEGRITY_MANIFEST}.js`)
diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx
index 8fb4139462576..5ee00bf8e79b4 100644
--- a/packages/next/src/client/components/layout-router.tsx
+++ b/packages/next/src/client/components/layout-router.tsx
@@ -25,6 +25,7 @@ import { ErrorBoundary } from './error-boundary'
 import { matchSegment } from './match-segments'
 import { useRouter } from './navigation'
 import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll'
+import { getURLFromRedirectError, isRedirectError } from './redirect'
 
 /**
  * Add refetch marker to router state at the point of the current layout segment.
@@ -380,8 +381,8 @@ class RedirectErrorBoundary extends React.Component<
   }
 
   static getDerivedStateFromError(error: any) {
-    if (error?.digest?.startsWith('NEXT_REDIRECT')) {
-      const url = error.digest.split(';')[1]
+    if (isRedirectError(error)) {
+      const url = getURLFromRedirectError(error)
       return { redirect: url }
     }
     // Re-throw if error is not for redirect
diff --git a/packages/next/src/client/components/not-found.ts b/packages/next/src/client/components/not-found.ts
index 2ca5e894be9bb..b576e6000600a 100644
--- a/packages/next/src/client/components/not-found.ts
+++ b/packages/next/src/client/components/not-found.ts
@@ -1,8 +1,25 @@
-export const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND'
+const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND'
 
+type NotFoundError = Error & { digest: typeof NOT_FOUND_ERROR_CODE }
+
+/**
+ * When used in a React server component, this will set the status code to 404.
+ * When used in a custom app route it will just send a 404 status.
+ */
 export function notFound(): never {
   // eslint-disable-next-line no-throw-literal
   const error = new Error(NOT_FOUND_ERROR_CODE)
-  ;(error as any).digest = NOT_FOUND_ERROR_CODE
+  ;(error as NotFoundError).digest = NOT_FOUND_ERROR_CODE
   throw error
 }
+
+/**
+ * Checks an error to determine if it's an error generated by the `notFound()`
+ * helper.
+ *
+ * @param error the error that may reference a not found error
+ * @returns true if the error is a not found error
+ */
+export function isNotFoundError(error: any): error is NotFoundError {
+  return error?.digest === NOT_FOUND_ERROR_CODE
+}
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx
index 1bd38c1a6dfc9..9a8232d95b253 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx
@@ -12,39 +12,34 @@ function FrameworkGroup({
   stackFrames: StackFramesGroup['stackFrames']
   all: boolean
 }) {
-  const [open, setOpen] = React.useState(false)
-  const toggleOpen = React.useCallback(() => setOpen((v) => !v), [])
-
   return (
     <>
-      <button
-        data-nextjs-call-stack-framework-button
-        data-state={open ? 'open' : 'closed'}
-        onClick={toggleOpen}
-        tabIndex={10} // Match CallStackFrame tabIndex
-      >
-        <svg
-          data-nextjs-call-stack-chevron-icon
-          fill="none"
-          height="20"
-          width="20"
-          shapeRendering="geometricPrecision"
-          stroke="currentColor"
-          strokeLinecap="round"
-          strokeLinejoin="round"
-          strokeWidth="2"
-          viewBox="0 0 24 24"
+      <details data-nextjs-collapsed-call-stack-details>
+        <summary
+          tabIndex={10} // Match CallStackFrame tabIndex
         >
-          <path d="M9 18l6-6-6-6" />
-        </svg>
-        <FrameworkIcon framework={framework} />
-        {framework === 'react' ? 'React' : 'Next.js'}
-      </button>
-      {open
-        ? stackFrames.map((frame, index) => (
-            <CallStackFrame key={`call-stack-${index}-${all}`} frame={frame} />
-          ))
-        : null}
+          <svg
+            data-nextjs-call-stack-chevron-icon
+            fill="none"
+            height="20"
+            width="20"
+            shapeRendering="geometricPrecision"
+            stroke="currentColor"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth="2"
+            viewBox="0 0 24 24"
+          >
+            <path d="M9 18l6-6-6-6" />
+          </svg>
+          <FrameworkIcon framework={framework} />
+          {framework === 'react' ? 'React' : 'Next.js'}
+        </summary>
+
+        {stackFrames.map((frame, index) => (
+          <CallStackFrame key={`call-stack-${index}-${all}`} frame={frame} />
+        ))}
+      </details>
     </>
   )
 }
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
index bc62d5bb59bf6..3d9d00d1a8f35 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
+++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx
@@ -178,14 +178,6 @@ export const styles = css`
     display: unset;
   }
 
-  [data-nextjs-call-stack-framework-button] {
-    border: none;
-    background: none;
-    display: flex;
-    align-items: center;
-    padding: 0;
-    margin: var(--size-gap-double) 0;
-  }
   [data-nextjs-call-stack-framework-icon] {
     margin-right: var(--size-gap);
   }
@@ -195,10 +187,26 @@ export const styles = css`
   [data-nextjs-call-stack-framework-icon='react'] {
     color: rgb(20, 158, 202);
   }
-  [data-nextjs-call-stack-framework-button][data-state='open']
-    > [data-nextjs-call-stack-chevron-icon] {
+  [data-nextjs-collapsed-call-stack-details][open]
+    [data-nextjs-call-stack-chevron-icon] {
     transform: rotate(90deg);
   }
+  [data-nextjs-collapsed-call-stack-details] summary {
+    display: flex;
+    align-items: center;
+    margin: var(--size-gap-double) 0;
+    list-style: none;
+  }
+  [data-nextjs-collapsed-call-stack-details] summary::-webkit-details-marker {
+    display: none;
+  }
+
+  [data-nextjs-collapsed-call-stack-details] h6 {
+    color: #666;
+  }
+  [data-nextjs-collapsed-call-stack-details] [data-nextjs-call-stack-frame] {
+    margin-bottom: var(--size-gap-double);
+  }
 `
 
 export { RuntimeError }
diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts
index 90ea53e8cf547..87f443f200fc9 100644
--- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts
+++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts
@@ -1,4 +1,6 @@
 import { useEffect } from 'react'
+import { isNotFoundError } from '../../../not-found'
+import { isRedirectError } from '../../../redirect'
 import {
   hydrationErrorWarning,
   hydrationErrorComponentStack,
@@ -12,10 +14,7 @@ export const RuntimeErrorHandler = {
 
 function isNextRouterError(error: any): boolean {
   return (
-    error &&
-    error.digest &&
-    (error.digest.startsWith('NEXT_REDIRECT') ||
-      error.digest === 'NEXT_NOT_FOUND')
+    error && error.digest && (isRedirectError(error) || isNotFoundError(error))
   )
 }
 
diff --git a/packages/next/src/client/components/redirect.test.ts b/packages/next/src/client/components/redirect.test.ts
index 5c19b8b99bee9..944ec9b8a23a3 100644
--- a/packages/next/src/client/components/redirect.test.ts
+++ b/packages/next/src/client/components/redirect.test.ts
@@ -1,13 +1,13 @@
 /* eslint-disable jest/no-try-expect */
-import { redirect, REDIRECT_ERROR_CODE } from './redirect'
+import { getURLFromRedirectError, isRedirectError, redirect } from './redirect'
 describe('test', () => {
   it('should throw a redirect error', () => {
     try {
       redirect('/dashboard')
       throw new Error('did not throw')
     } catch (err: any) {
-      expect(err.message).toBe(REDIRECT_ERROR_CODE)
-      expect(err.digest).toBe(`${REDIRECT_ERROR_CODE};/dashboard`)
+      expect(isRedirectError(err)).toBeTruthy()
+      expect(getURLFromRedirectError(err)).toEqual('/dashboard')
     }
   })
 })
diff --git a/packages/next/src/client/components/redirect.ts b/packages/next/src/client/components/redirect.ts
index 5e685f94edbe0..4dee12b8d5f6e 100644
--- a/packages/next/src/client/components/redirect.ts
+++ b/packages/next/src/client/components/redirect.ts
@@ -1,8 +1,55 @@
-export const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'
+const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'
 
+type RedirectError<U extends string> = Error & {
+  digest: `${typeof REDIRECT_ERROR_CODE};${U}`
+}
+
+/**
+ * When used in a React server component, this will insert a meta tag to
+ * redirect the user to the target page. When used in a custom app route, it
+ * will serve a 302 to the caller.
+ *
+ * @param url the url to redirect to
+ */
 export function redirect(url: string): never {
   // eslint-disable-next-line no-throw-literal
   const error = new Error(REDIRECT_ERROR_CODE)
-  ;(error as any).digest = REDIRECT_ERROR_CODE + ';' + url
+  ;(error as RedirectError<typeof url>).digest = `${REDIRECT_ERROR_CODE};${url}`
   throw error
 }
+
+/**
+ * Checks an error to determine if it's an error generated by the
+ * `redirect(url)` helper.
+ *
+ * @param error the error that may reference a redirect error
+ * @returns true if the error is a redirect error
+ */
+export function isRedirectError<U extends string>(
+  error: any
+): error is RedirectError<U> {
+  return (
+    typeof error?.digest === 'string' &&
+    error.digest.startsWith(REDIRECT_ERROR_CODE + ';') &&
+    error.digest.length > REDIRECT_ERROR_CODE.length + 1
+  )
+}
+
+/**
+ * Returns the encoded URL from the error if it's a RedirectError, null
+ * otherwise. Note that this does not validate the URL returned.
+ *
+ * @param error the error that may be a redirect error
+ * @return the url if the error was a redirect error
+ */
+export function getURLFromRedirectError<U extends string>(
+  error: RedirectError<U>
+): U
+export function getURLFromRedirectError(error: any): string | null
+export function getURLFromRedirectError(error: any): string | null {
+  if (!isRedirectError(error)) return null
+
+  // Slices off the beginning of the digest that contains the code and the
+  // separating ';'.
+  return error.digest.slice(REDIRECT_ERROR_CODE.length + 1)
+}
diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts
index 79a92a37dd876..4ae64610624e5 100644
--- a/packages/next/src/export/index.ts
+++ b/packages/next/src/export/index.ts
@@ -22,7 +22,7 @@ import {
   CLIENT_STATIC_FILES_PATH,
   EXPORT_DETAIL,
   EXPORT_MARKER,
-  FLIGHT_MANIFEST,
+  CLIENT_REFERENCE_MANIFEST,
   FLIGHT_SERVER_CSS_MANIFEST,
   FONT_LOADER_MANIFEST,
   MIDDLEWARE_MANIFEST,
@@ -441,7 +441,7 @@ export default async function exportApp(
       renderOpts.serverComponentManifest = require(join(
         distDir,
         SERVER_DIRECTORY,
-        `${FLIGHT_MANIFEST}.json`
+        `${CLIENT_REFERENCE_MANIFEST}.json`
       )) as PagesManifest
       // @ts-expect-error untyped
       renderOpts.serverCSSManifest = require(join(
diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts
index 28f6b00388430..5a41cce1cf67f 100644
--- a/packages/next/src/export/worker.ts
+++ b/packages/next/src/export/worker.ts
@@ -29,11 +29,11 @@ import RenderResult from '../server/render-result'
 import isError from '../lib/is-error'
 import { addRequestMeta } from '../server/request-meta'
 import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
-import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
 import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'
-import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found'
-import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error'
 import { IncrementalCache } from '../server/lib/incremental-cache'
+import { isNotFoundError } from '../client/components/not-found'
+import { isRedirectError } from '../client/components/redirect'
+import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error'
 
 loadRequireHook()
 
@@ -391,9 +391,9 @@ export default async function exportPage({
         } catch (err: any) {
           if (
             err.digest !== DYNAMIC_ERROR_CODE &&
-            err.digest !== NOT_FOUND_ERROR_CODE &&
+            !isNotFoundError(err) &&
             err.digest !== NEXT_DYNAMIC_NO_SSR_CODE &&
-            !err.digest?.startsWith(REDIRECT_ERROR_CODE)
+            !isRedirectError(err)
           ) {
             throw err
           }
diff --git a/packages/next/src/lib/is-app-page-route.ts b/packages/next/src/lib/is-app-page-route.ts
new file mode 100644
index 0000000000000..39b4220d51a2e
--- /dev/null
+++ b/packages/next/src/lib/is-app-page-route.ts
@@ -0,0 +1,3 @@
+export function isAppPageRoute(route: string): boolean {
+  return route.endsWith('/page')
+}
diff --git a/packages/next/src/lib/is-app-route-route.ts b/packages/next/src/lib/is-app-route-route.ts
new file mode 100644
index 0000000000000..26a048d177e68
--- /dev/null
+++ b/packages/next/src/lib/is-app-route-route.ts
@@ -0,0 +1,3 @@
+export function isAppRouteRoute(route: string): boolean {
+  return route.endsWith('/route')
+}
diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx
index 545e1aafff2f5..9f5971b0627b4 100644
--- a/packages/next/src/server/app-render.tsx
+++ b/packages/next/src/server/app-render.tsx
@@ -30,11 +30,8 @@ import {
 } from '../build/webpack/plugins/flight-manifest-plugin'
 import { ServerInsertedHTMLContext } from '../shared/lib/server-inserted-html'
 import { stripInternalQueries } from './internal-utils'
-import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
 import { RequestCookies } from './web/spec-extension/cookies'
 import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'
-import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found'
-import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error'
 import { HeadManagerContext } from '../shared/lib/head-manager-context'
 import stringHash from 'next/dist/compiled/string-hash'
 import {
@@ -55,6 +52,9 @@ import type { MetadataItems } from '../lib/metadata/resolve-metadata'
 import { isClientReference } from '../build/is-client-reference'
 import { getLayoutOrPageModule, LoaderTree } from './lib/app-dir-module'
 import { warnOnce } from '../shared/lib/utils/warn-once'
+import { isNotFoundError } from '../client/components/not-found'
+import { isRedirectError } from '../client/components/redirect'
+import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error'
 
 const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
 
@@ -240,9 +240,9 @@ function createErrorHandler(
     if (
       err &&
       (err.digest === DYNAMIC_ERROR_CODE ||
-        err.digest === NOT_FOUND_ERROR_CODE ||
+        isNotFoundError(err) ||
         err.digest === NEXT_DYNAMIC_NO_SSR_CODE ||
-        err.digest?.startsWith(REDIRECT_ERROR_CODE))
+        isRedirectError(err))
     ) {
       return err.digest
     }
@@ -2028,11 +2028,11 @@ export async function renderToHTMLOrFlight(
 
         return result
       } catch (err: any) {
-        const shouldNotIndex = err.digest === NOT_FOUND_ERROR_CODE
-        if (err.digest === NOT_FOUND_ERROR_CODE) {
+        const shouldNotIndex = isNotFoundError(err)
+        if (isNotFoundError(err)) {
           res.statusCode = 404
         }
-        if (err.digest?.startsWith(REDIRECT_ERROR_CODE)) {
+        if (isRedirectError(err)) {
           res.statusCode = 307
         }
 
diff --git a/packages/next/src/server/async-storage/async-storage-wrapper.ts b/packages/next/src/server/async-storage/async-storage-wrapper.ts
new file mode 100644
index 0000000000000..9845c21617661
--- /dev/null
+++ b/packages/next/src/server/async-storage/async-storage-wrapper.ts
@@ -0,0 +1,21 @@
+import type { AsyncLocalStorage } from 'async_hooks'
+
+/**
+ * Implementations provide a wrapping function that will provide the storage to
+ * async calls derived from the provided callback function.
+ */
+export interface AsyncStorageWrapper<Store extends {}, Context extends {}> {
+  /**
+   * Wraps the callback with the underlying storage.
+   *
+   * @param storage underlying storage object
+   * @param context context used to create the storage object
+   * @param callback function to call within the scope of the storage
+   * @returns the result of the callback
+   */
+  wrap<Result>(
+    storage: AsyncLocalStorage<Store>,
+    context: Context,
+    callback: () => Result
+  ): Result
+}
diff --git a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts
new file mode 100644
index 0000000000000..9216e7f75dc06
--- /dev/null
+++ b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts
@@ -0,0 +1,110 @@
+import { FLIGHT_PARAMETERS } from '../../client/components/app-router-headers'
+import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'
+import type { AsyncLocalStorage } from 'async_hooks'
+import type { PreviewData } from '../../../types'
+import type { RequestStore } from '../../client/components/request-async-storage'
+import {
+  ReadonlyHeaders,
+  ReadonlyRequestCookies,
+  type RenderOpts,
+} from '../app-render'
+import { AsyncStorageWrapper } from './async-storage-wrapper'
+import type { tryGetPreviewData } from '../api-utils/node'
+
+function headersWithoutFlight(headers: IncomingHttpHeaders) {
+  const newHeaders = { ...headers }
+  for (const param of FLIGHT_PARAMETERS) {
+    delete newHeaders[param.toString().toLowerCase()]
+  }
+  return newHeaders
+}
+
+export type RequestContext = {
+  req: IncomingMessage
+  res: ServerResponse
+  renderOpts?: RenderOpts
+}
+
+export class RequestAsyncStorageWrapper
+  implements AsyncStorageWrapper<RequestStore, RequestContext>
+{
+  /**
+   * Tries to get the preview data on the request for the given route. This
+   * isn't enabled in the edge runtime yet.
+   */
+  private static readonly tryGetPreviewData: typeof tryGetPreviewData | null =
+    process.env.NEXT_RUNTIME !== 'edge'
+      ? require('../api-utils/node').tryGetPreviewData
+      : null
+
+  /**
+   * Wrap the callback with the given store so it can access the underlying
+   * store using hooks.
+   *
+   * @param storage underlying storage object returned by the module
+   * @param context context to seed the store
+   * @param callback function to call within the scope of the context
+   * @returns the result returned by the callback
+   */
+  public wrap<Result>(
+    storage: AsyncLocalStorage<RequestStore>,
+    context: RequestContext,
+    callback: () => Result
+  ): Result {
+    return RequestAsyncStorageWrapper.wrap(storage, context, callback)
+  }
+
+  /**
+   * @deprecated instance method should be used in favor of the static method
+   */
+  public static wrap<Result>(
+    storage: AsyncLocalStorage<RequestStore>,
+    { req, res, renderOpts }: RequestContext,
+    callback: () => Result
+  ): Result {
+    // Reads of this are cached on the `req` object, so this should resolve
+    // instantly. There's no need to pass this data down from a previous
+    // invoke, where we'd have to consider server & serverless.
+    const previewData: PreviewData =
+      renderOpts && RequestAsyncStorageWrapper.tryGetPreviewData
+        ? // TODO: investigate why previewProps isn't on RenderOpts
+          RequestAsyncStorageWrapper.tryGetPreviewData(
+            req,
+            res,
+            (renderOpts as any).previewProps
+          )
+        : false
+
+    let cachedHeadersInstance: ReadonlyHeaders
+    let cachedCookiesInstance: ReadonlyRequestCookies
+
+    const store: RequestStore = {
+      get headers() {
+        if (!cachedHeadersInstance) {
+          cachedHeadersInstance = new ReadonlyHeaders(
+            headersWithoutFlight(req.headers)
+          )
+        }
+        return cachedHeadersInstance
+      },
+      get cookies() {
+        if (!cachedCookiesInstance) {
+          cachedCookiesInstance = new ReadonlyRequestCookies({
+            headers: {
+              get: (key) => {
+                if (key !== 'cookie') {
+                  throw new Error('Only cookie header is supported')
+                }
+                return req.headers.cookie
+              },
+            },
+          })
+        }
+        return cachedCookiesInstance
+      },
+      previewData,
+    }
+
+    return storage.run(store, callback)
+  }
+}
diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts
new file mode 100644
index 0000000000000..1222b2820c28b
--- /dev/null
+++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts
@@ -0,0 +1,55 @@
+import { AsyncStorageWrapper } from './async-storage-wrapper'
+import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage'
+import type { RenderOpts } from '../app-render'
+import type { AsyncLocalStorage } from 'async_hooks'
+
+export type RequestContext = {
+  pathname: string
+  renderOpts: RenderOpts
+}
+
+export class StaticGenerationAsyncStorageWrapper
+  implements AsyncStorageWrapper<StaticGenerationStore, RequestContext>
+{
+  public wrap<Result>(
+    storage: AsyncLocalStorage<StaticGenerationStore>,
+    context: RequestContext,
+    callback: () => Result
+  ): Result {
+    return StaticGenerationAsyncStorageWrapper.wrap(storage, context, callback)
+  }
+
+  /**
+   * @deprecated instance method should be used in favor of the static method
+   */
+  public static wrap<Result>(
+    storage: AsyncLocalStorage<StaticGenerationStore>,
+    { pathname, renderOpts }: RequestContext,
+    callback: () => Result
+  ): Result {
+    /**
+     * Rules of Static & Dynamic HTML:
+     *
+     *    1.) We must generate static HTML unless the caller explicitly opts
+     *        in to dynamic HTML support.
+     *
+     *    2.) If dynamic HTML support is requested, we must honor that request
+     *        or throw an error. It is the sole responsibility of the caller to
+     *        ensure they aren't e.g. requesting dynamic HTML for an AMP page.
+     *
+     * These rules help ensure that other existing features like request caching,
+     * coalescing, and ISR continue working as intended.
+     */
+    const isStaticGeneration =
+      renderOpts.supportsDynamicHTML !== true && !renderOpts.isBot
+
+    const store: StaticGenerationStore = {
+      isStaticGeneration,
+      pathname,
+      incrementalCache: renderOpts.incrementalCache,
+      isRevalidate: renderOpts.isRevalidate,
+    }
+
+    return storage.run(store, callback)
+  }
+}
diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts
index 4cf2d193bf15a..a23125e871945 100644
--- a/packages/next/src/server/base-server.ts
+++ b/packages/next/src/server/base-server.ts
@@ -1,10 +1,10 @@
 import type { __ApiPreviewProps } from './api-utils'
 import type { CustomRoutes } from '../lib/load-custom-routes'
 import type { DomainLocale } from './config'
-import type { DynamicRoutes, PageChecker, Route } from './router'
+import type { RouterOptions } from './router'
 import type { FontManifest, FontConfig } from './font-utils'
 import type { LoadComponentsReturnType } from './load-components'
-import type { RouteMatch } from '../shared/lib/router/utils/route-matcher'
+import type { RouteMatchFn } from '../shared/lib/router/utils/route-matcher'
 import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher'
 import type { Params } from '../shared/lib/router/utils/route-matcher'
 import type { NextConfig, NextConfigComplete } from './config-shared'
@@ -34,11 +34,13 @@ import { format as formatUrl, parse as parseUrl } from 'url'
 import { getRedirectStatus } from '../lib/redirect-status'
 import { isEdgeRuntime } from '../lib/is-edge-runtime'
 import {
+  APP_PATHS_MANIFEST,
   NEXT_BUILTIN_DOCUMENT,
+  PAGES_MANIFEST,
   STATIC_STATUS_PAGES,
   TEMPORARY_REDIRECT_STATUS,
 } from '../shared/lib/constants'
-import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils'
+import { isDynamicRoute } from '../shared/lib/router/utils'
 import {
   setLazyProp,
   getCookieParser,
@@ -68,8 +70,6 @@ import {
   normalizeAppPath,
   normalizeRscPath,
 } from '../shared/lib/router/utils/app-paths'
-import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher'
-import { getRouteRegex } from '../shared/lib/router/utils/route-regex'
 import { getHostname } from '../shared/lib/get-hostname'
 import { parseUrl as parseUrlUtil } from '../shared/lib/router/utils/parse-url'
 import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info'
@@ -80,6 +80,18 @@ import {
   FLIGHT_PARAMETERS,
   FETCH_CACHE_HEADER,
 } from '../client/components/app-router-headers'
+import {
+  MatchOptions,
+  RouteMatcherManager,
+} from './future/route-matcher-managers/route-matcher-manager'
+import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager'
+import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer'
+import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager'
+import { AppPageRouteMatcherProvider } from './future/route-matcher-providers/app-page-route-matcher-provider'
+import { AppRouteRouteMatcherProvider } from './future/route-matcher-providers/app-route-route-matcher-provider'
+import { PagesAPIRouteMatcherProvider } from './future/route-matcher-providers/pages-api-route-matcher-provider'
+import { PagesRouteMatcherProvider } from './future/route-matcher-providers/pages-route-matcher-provider'
+import { ServerManifestLoader } from './future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader'
 
 export type FindComponentsResult = {
   components: LoadComponentsReturnType
@@ -88,7 +100,7 @@ export type FindComponentsResult = {
 
 export interface RoutingItem {
   page: string
-  match: RouteMatch
+  match: RouteMatchFn
   re?: RegExp
 }
 
@@ -173,18 +185,18 @@ type ResponsePayload = {
 }
 
 export default abstract class Server<ServerOptions extends Options = Options> {
-  protected dir: string
-  protected quiet: boolean
-  protected nextConfig: NextConfigComplete
-  protected distDir: string
-  protected publicDir: string
-  protected hasStaticDir: boolean
-  protected hasAppDir: boolean
-  protected pagesManifest?: PagesManifest
-  protected appPathsManifest?: PagesManifest
-  protected buildId: string
-  protected minimalMode: boolean
-  protected renderOpts: {
+  protected readonly dir: string
+  protected readonly quiet: boolean
+  protected readonly nextConfig: NextConfigComplete
+  protected readonly distDir: string
+  protected readonly publicDir: string
+  protected readonly hasStaticDir: boolean
+  protected readonly hasAppDir: boolean
+  protected readonly pagesManifest?: PagesManifest
+  protected readonly appPathsManifest?: PagesManifest
+  protected readonly buildId: string
+  protected readonly minimalMode: boolean
+  protected readonly renderOpts: {
     poweredByHeader: boolean
     buildId: string
     generateEtags: boolean
@@ -222,7 +234,6 @@ export default abstract class Server<ServerOptions extends Options = Options> {
   protected serverOptions: ServerOptions
   private responseCache: ResponseCacheBase
   protected router: Router
-  protected dynamicRoutes?: DynamicRoutes
   protected appPathRoutes?: Record<string, string[]>
   protected customRoutes: CustomRoutes
   protected serverComponentManifest?: any
@@ -244,8 +255,9 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     query: NextParsedUrlQuery
     params: Params
     isAppPath: boolean
-    appPaths?: string[] | null
     sriEnabled?: boolean
+    appPaths?: string[] | null
+    shouldEnsure: boolean
   }): Promise<FindComponentsResult | null>
   protected abstract getFontManifest(): FontManifest | undefined
   protected abstract getPrerenderManifest(): PrerenderManifest
@@ -260,22 +272,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
   protected abstract getCustomRoutes(): CustomRoutes
   protected abstract hasPage(pathname: string): Promise<boolean>
 
-  protected abstract generateRoutes(): {
-    headers: Route[]
-    rewrites: {
-      beforeFiles: Route[]
-      afterFiles: Route[]
-      fallback: Route[]
-    }
-    fsRoutes: Route[]
-    redirects: Route[]
-    catchAllRoute: Route
-    catchAllMiddleware: Route[]
-    pageChecker: PageChecker
-    useFileSystemPublicRoutes: boolean
-    dynamicRoutes: DynamicRoutes | undefined
-    nextConfig: NextConfig
-  }
+  protected abstract generateRoutes(): RouterOptions
 
   protected abstract sendRenderResult(
     req: BaseNextRequest,
@@ -324,6 +321,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     forceReload?: boolean
   }): void
 
+  protected readonly matchers: RouteMatcherManager
+  protected readonly handlers: RouteHandlerManager
+  protected readonly localeNormalizer?: LocaleRouteNormalizer
+
   public constructor(options: ServerOptions) {
     const {
       dir = '.',
@@ -355,6 +356,15 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     this.publicDir = this.getPublicDir()
     this.hasStaticDir = !minimalMode && this.getHasStaticDir()
 
+    // Configure the locale normalizer, it's used for routes inside `pages/`.
+    this.localeNormalizer =
+      this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale
+        ? new LocaleRouteNormalizer(
+            this.nextConfig.i18n.locales,
+            this.nextConfig.i18n.defaultLocale
+          )
+        : undefined
+
     // Only serverRuntimeConfig needs the default
     // publicRuntimeConfig gets it's default in client/index.js
     const {
@@ -408,12 +418,12 @@ export default abstract class Server<ServerOptions extends Options = Options> {
         ? this.nextConfig.crossOrigin
         : undefined,
       largePageDataBytes: this.nextConfig.experimental.largePageDataBytes,
-    }
-
-    // Only the `publicRuntimeConfig` key is exposed to the client side
-    // It'll be rendered as part of __NEXT_DATA__ on the client side
-    if (Object.keys(publicRuntimeConfig).length > 0) {
-      this.renderOpts.runtimeConfig = publicRuntimeConfig
+      // Only the `publicRuntimeConfig` key is exposed to the client side
+      // It'll be rendered as part of __NEXT_DATA__ on the client side
+      runtimeConfig:
+        Object.keys(publicRuntimeConfig).length > 0
+          ? publicRuntimeConfig
+          : undefined,
     }
 
     // Initialize next/config with the environment configuration
@@ -425,6 +435,16 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     this.pagesManifest = this.getPagesManifest()
     this.appPathsManifest = this.getAppPathsManifest()
 
+    // Configure the routes.
+    const { matchers, handlers } = this.getRoutes()
+    this.matchers = matchers
+    this.handlers = handlers
+
+    // Start route compilation. We don't wait for the routes to finish loading
+    // because we use the `waitTillReady` promise below in `handleRequest` to
+    // wait. Also we can't `await` in the constructor.
+    matchers.reload()
+
     this.customRoutes = this.getCustomRoutes()
     this.router = new Router(this.generateRoutes())
     this.setAssetPrefix(assetPrefix)
@@ -432,6 +452,58 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     this.responseCache = this.getResponseCache({ dev })
   }
 
+  protected getRoutes(): {
+    matchers: RouteMatcherManager
+    handlers: RouteHandlerManager
+  } {
+    // Create a new manifest loader that get's the manifests from the server.
+    const manifestLoader = new ServerManifestLoader((name) => {
+      switch (name) {
+        case PAGES_MANIFEST:
+          return this.getPagesManifest() ?? null
+        case APP_PATHS_MANIFEST:
+          return this.getAppPathsManifest() ?? null
+        default:
+          return null
+      }
+    })
+
+    // Configure the matchers and handlers.
+    const matchers: RouteMatcherManager = new DefaultRouteMatcherManager()
+    const handlers = new RouteHandlerManager()
+
+    // Match pages under `pages/`.
+    matchers.push(
+      new PagesRouteMatcherProvider(
+        this.distDir,
+        manifestLoader,
+        this.localeNormalizer
+      )
+    )
+
+    // Match api routes under `pages/api/`.
+    matchers.push(
+      new PagesAPIRouteMatcherProvider(
+        this.distDir,
+        manifestLoader,
+        this.localeNormalizer
+      )
+    )
+
+    // If the app directory is enabled, then add the app matchers and handlers.
+    if (this.hasAppDir) {
+      // Match app pages under `app/`.
+      matchers.push(
+        new AppPageRouteMatcherProvider(this.distDir, manifestLoader)
+      )
+      matchers.push(
+        new AppRouteRouteMatcherProvider(this.distDir, manifestLoader)
+      )
+    }
+
+    return { matchers, handlers }
+  }
+
   public logError(err: Error): void {
     if (this.quiet) return
     console.error(err)
@@ -443,6 +515,9 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     parsedUrl?: NextUrlWithParsedQuery
   ): Promise<void> {
     try {
+      // Wait for the matchers to be ready.
+      await this.matchers.waitTillReady()
+
       // ensure cookies set in middleware are merged and
       // not overridden by API routes/getServerSideProps
       const _res = (res as any).originalResponse || res
@@ -559,36 +634,33 @@ export default abstract class Server<ServerOptions extends Options = Options> {
           if (urlPathname.startsWith(`/_next/data/`)) {
             parsedUrl.query.__nextDataReq = '1'
           }
+
           const normalizedUrlPath = this.stripNextDataPath(urlPathname)
           matchedPath = this.stripNextDataPath(matchedPath, false)
 
-          if (this.nextConfig.i18n) {
-            const localeResult = normalizeLocalePath(
-              matchedPath,
-              this.nextConfig.i18n.locales
-            )
-            matchedPath = localeResult.pathname
-
-            if (localeResult.detectedLocale) {
-              parsedUrl.query.__nextLocale = localeResult.detectedLocale
-            }
+          // Perform locale detection and normalization.
+          const options: MatchOptions = {
+            i18n: this.localeNormalizer?.match(matchedPath),
           }
+          if (options.i18n?.detectedLocale) {
+            parsedUrl.query.__nextLocale = options.i18n.detectedLocale
+          }
+
+          // TODO: check if this is needed any more?
           matchedPath = denormalizePagePath(matchedPath)
-          let srcPathname = matchedPath
 
-          if (
-            !isDynamicRoute(srcPathname) &&
-            !(await this.hasPage(removeTrailingSlash(srcPathname)))
-          ) {
-            for (const dynamicRoute of this.dynamicRoutes || []) {
-              if (dynamicRoute.match(srcPathname)) {
-                srcPathname = dynamicRoute.page
-                break
-              }
-            }
+          let srcPathname = matchedPath
+          const match = await this.matchers.match(matchedPath, options)
+          if (match) {
+            srcPathname = match.definition.pathname
           }
+          const pageIsDynamic = typeof match?.params !== 'undefined'
+
+          // The rest of this function can't handle i18n properly, so ensure we
+          // restore the pathname with the locale information stripped from it
+          // now that we're done matching.
+          matchedPath = options.i18n?.pathname ?? matchedPath
 
-          const pageIsDynamic = isDynamicRoute(srcPathname)
           const utils = getUtils({
             pageIsDynamic,
             page: srcPathname,
@@ -803,34 +875,11 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     return false
   }
 
-  protected getDynamicRoutes(): Array<RoutingItem> {
-    const addedPages = new Set<string>()
-
-    return getSortedRoutes(
-      [
-        ...Object.keys(this.appPathRoutes || {}),
-        ...Object.keys(this.pagesManifest!),
-      ].map(
-        (page) =>
-          normalizeLocalePath(page, this.nextConfig.i18n?.locales).pathname
-      )
-    )
-      .map((page) => {
-        if (addedPages.has(page) || !isDynamicRoute(page)) return null
-        addedPages.add(page)
-        return {
-          page,
-          match: getRouteMatcher(getRouteRegex(page)),
-        }
-      })
-      .filter((item): item is RoutingItem => Boolean(item))
-  }
-
   protected getAppPathRoutes(): Record<string, string[]> {
     const appPathRoutes: Record<string, string[]> = {}
 
     Object.keys(this.appPathsManifest || {}).forEach((entry) => {
-      const normalizedPath = normalizeAppPath(entry) || '/'
+      const normalizedPath = normalizeAppPath(entry)
       if (!appPathRoutes[normalizedPath]) {
         appPathRoutes[normalizedPath] = []
       }
@@ -1691,8 +1740,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
       query,
       params: ctx.renderOpts.params || {},
       isAppPath,
-      appPaths,
       sriEnabled: !!this.nextConfig.experimental.sri?.algorithm,
+      appPaths,
+      // Ensuring for loading page component routes is done via the matcher.
+      shouldEnsure: false,
     })
     if (result) {
       try {
@@ -1716,34 +1767,24 @@ export default abstract class Server<ServerOptions extends Options = Options> {
     const bubbleNoFallback = !!query._nextBubbleNoFallback
     delete query._nextBubbleNoFallback
 
-    try {
-      // Ensure a request to the URL /accounts/[id] will be treated as a dynamic
-      // route correctly and not loaded immediately without parsing params.
-      if (!isDynamicRoute(page)) {
-        const result = await this.renderPageComponent(ctx, bubbleNoFallback)
-        if (result !== false) return result
-      }
+    const options: MatchOptions = {
+      i18n: this.localeNormalizer?.match(pathname),
+    }
 
-      if (this.dynamicRoutes) {
-        for (const dynamicRoute of this.dynamicRoutes) {
-          const params = dynamicRoute.match(pathname)
-          if (!params) {
-            continue
-          }
-          page = dynamicRoute.page
-          const result = await this.renderPageComponent(
-            {
-              ...ctx,
-              pathname: page,
-              renderOpts: {
-                ...ctx.renderOpts,
-                params,
-              },
+    try {
+      for await (const match of this.matchers.matchAll(pathname, options)) {
+        const result = await this.renderPageComponent(
+          {
+            ...ctx,
+            pathname: match.definition.pathname,
+            renderOpts: {
+              ...ctx.renderOpts,
+              params: match.params,
             },
-            bubbleNoFallback
-          )
-          if (result !== false) return result
-        }
+          },
+          bubbleNoFallback
+        )
+        if (result !== false) return result
       }
 
       // currently edge functions aren't receiving the x-matched-path
@@ -1901,6 +1942,8 @@ export default abstract class Server<ServerOptions extends Options = Options> {
           query,
           params: {},
           isAppPath: false,
+          // Ensuring can't be done here because you never "match" a 404 route.
+          shouldEnsure: true,
         })
         using404Page = result !== null
       }
@@ -1919,6 +1962,9 @@ export default abstract class Server<ServerOptions extends Options = Options> {
             query,
             params: {},
             isAppPath: false,
+            // Ensuring can't be done here because you never "match" a 500
+            // route.
+            shouldEnsure: true,
           })
         }
       }
@@ -1929,6 +1975,9 @@ export default abstract class Server<ServerOptions extends Options = Options> {
           query,
           params: {},
           isAppPath: false,
+          // Ensuring can't be done here because you never "match" an error
+          // route.
+          shouldEnsure: true,
         })
         statusPage = '/_error'
       }
diff --git a/packages/next/src/server/body-streams.ts b/packages/next/src/server/body-streams.ts
index 2053240d92351..2370aac8fa5a8 100644
--- a/packages/next/src/server/body-streams.ts
+++ b/packages/next/src/server/body-streams.ts
@@ -32,14 +32,14 @@ function replaceRequestBody<T extends IncomingMessage>(
   return base
 }
 
-export interface ClonableBody {
+export interface CloneableBody {
   finalize(): Promise<void>
   cloneBodyStream(): Readable
 }
 
-export function getClonableBody<T extends IncomingMessage>(
+export function getCloneableBody<T extends IncomingMessage>(
   readable: T
-): ClonableBody {
+): CloneableBody {
   let buffered: Readable | null = null
 
   const endPromise = new Promise<void | { error?: unknown }>(
diff --git a/packages/next/src/server/dev/hot-reloader.ts b/packages/next/src/server/dev/hot-reloader.ts
index 3a62bbb60d696..ebc49f388699c 100644
--- a/packages/next/src/server/dev/hot-reloader.ts
+++ b/packages/next/src/server/dev/hot-reloader.ts
@@ -49,6 +49,7 @@ import ws from 'next/dist/compiled/ws'
 import { promises as fs } from 'fs'
 import { getPageStaticInfo } from '../../build/analysis/get-page-static-info'
 import { UnwrapPromise } from '../../lib/coalesced-function'
+import { RouteMatch } from '../future/route-matches/route-match'
 
 function diff(a: Set<any>, b: Set<any>) {
   return new Set([...a].filter((v) => !b.has(v)))
@@ -1148,10 +1149,12 @@ export default class HotReloader {
     page,
     clientOnly,
     appPaths,
+    match,
   }: {
     page: string
     clientOnly: boolean
     appPaths?: string[] | null
+    match?: RouteMatch
   }): Promise<void> {
     // Make sure we don't re-build or dispose prebuilt pages
     if (page !== '/_error' && BLOCKED_PAGES.indexOf(page) !== -1) {
@@ -1167,6 +1170,7 @@ export default class HotReloader {
       page,
       clientOnly,
       appPaths,
+      match,
     }) as any
   }
 }
diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts
index 1db31504bb3b4..3cdee0fe39186 100644
--- a/packages/next/src/server/dev/next-dev-server.ts
+++ b/packages/next/src/server/dev/next-dev-server.ts
@@ -32,6 +32,8 @@ import {
   DEV_CLIENT_PAGES_MANIFEST,
   DEV_MIDDLEWARE_MANIFEST,
   COMPILER_NAMES,
+  PAGES_MANIFEST,
+  APP_PATHS_MANIFEST,
 } from '../../shared/lib/constants'
 import Server, { WrappedBuildError } from '../next-server'
 import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher'
@@ -63,7 +65,7 @@ import {
 import * as Log from '../../build/output/log'
 import isError, { getProperError } from '../../lib/is-error'
 import { getRouteRegex } from '../../shared/lib/router/utils/route-regex'
-import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils'
+import { getSortedRoutes } from '../../shared/lib/router/utils'
 import { runDependingOnPageType } from '../../build/entries'
 import { NodeNextResponse, NodeNextRequest } from '../base-http/node'
 import { getPageStaticInfo } from '../../build/analysis/get-page-static-info'
@@ -78,6 +80,18 @@ import { getDefineEnv } from '../../build/webpack-config'
 import loadJsConfig from '../../build/load-jsconfig'
 import { formatServerError } from '../../lib/format-server-error'
 import { pageFiles } from '../../build/webpack/plugins/flight-types-plugin'
+import {
+  DevRouteMatcherManager,
+  RouteEnsurer,
+} from '../future/route-matcher-managers/dev-route-matcher-manager'
+import { DevPagesRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-pages-route-matcher-provider'
+import { DevPagesAPIRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider'
+import { DevAppPageRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-page-route-matcher-provider'
+import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-route-route-matcher-provider'
+import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin'
+import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader'
+import { CachedFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader'
+import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/default-file-reader'
 
 // Load ReactDevOverlay only when needed
 let ReactDevOverlayImpl: FunctionComponent
@@ -209,6 +223,66 @@ export default class DevServer extends Server {
     this.appDir = appDir
   }
 
+  protected getRoutes() {
+    const { pagesDir, appDir } = findPagesDir(
+      this.dir,
+      !!this.nextConfig.experimental.appDir
+    )
+
+    const ensurer: RouteEnsurer = {
+      ensure: async (match) => {
+        await this.hotReloader!.ensurePage({
+          match,
+          page: match.definition.page,
+          clientOnly: false,
+        })
+      },
+    }
+
+    const routes = super.getRoutes()
+    const matchers = new DevRouteMatcherManager(
+      routes.matchers,
+      ensurer,
+      this.dir
+    )
+    const handlers = routes.handlers
+
+    const extensions = this.nextConfig.pageExtensions
+
+    const fileReader = new CachedFileReader(new DefaultFileReader())
+
+    // If the pages directory is available, then configure those matchers.
+    if (pagesDir) {
+      matchers.push(
+        new DevPagesRouteMatcherProvider(
+          pagesDir,
+          extensions,
+          fileReader,
+          this.localeNormalizer
+        )
+      )
+      matchers.push(
+        new DevPagesAPIRouteMatcherProvider(
+          pagesDir,
+          extensions,
+          fileReader,
+          this.localeNormalizer
+        )
+      )
+    }
+
+    if (appDir) {
+      matchers.push(
+        new DevAppPageRouteMatcherProvider(appDir, extensions, fileReader)
+      )
+      matchers.push(
+        new DevAppRouteRouteMatcherProvider(appDir, extensions, fileReader)
+      )
+    }
+
+    return { matchers, handlers }
+  }
+
   protected getBuildId(): string {
     return 'development'
   }
@@ -423,7 +497,7 @@ export default class DevServer extends Server {
             }
 
             const originalPageName = pageName
-            pageName = normalizeAppPath(pageName) || '/'
+            pageName = normalizeAppPath(pageName)
             if (!appPaths[pageName]) {
               appPaths[pageName] = []
             }
@@ -636,14 +710,6 @@ export default class DevServer extends Server {
           }
           this.sortedRoutes = sortedRoutes
 
-          this.dynamicRoutes = this.sortedRoutes
-            .filter(isDynamicRoute)
-            .map((page) => ({
-              page,
-              match: getRouteMatcher(getRouteRegex(page)),
-            }))
-
-          this.router.setDynamicRoutes(this.dynamicRoutes)
           this.router.setCatchallMiddleware(
             this.generateCatchAllMiddlewareRoute(true)
           )
@@ -659,6 +725,10 @@ export default class DevServer extends Server {
           } else {
             Log.warn('Failed to reload dynamic routes:', e)
           }
+        } finally {
+          // Reload the matchers. The filesystem would have been written to,
+          // and the matchers need to re-scan it to update the router.
+          await this.matchers.reload()
         }
       })
     })
@@ -731,6 +801,7 @@ export default class DevServer extends Server {
     await this.addExportPathMapRoutes()
     await this.hotReloader.start()
     await this.startWatcher()
+    await this.matchers.reload()
     this.setDevReady!()
 
     if (this.nextConfig.experimental.nextScriptWorkers) {
@@ -852,7 +923,8 @@ export default class DevServer extends Server {
     }
 
     if (await this.hasPublicFile(decodedPath)) {
-      if (await this.hasPage(pathname!)) {
+      const match = await this.matchers.match(pathname!, { skipDynamic: true })
+      if (match) {
         const err = new Error(
           `A conflicting public file and page file was found for path ${pathname} https://nextjs.org/docs/messages/conflicting-public-file-page`
         )
@@ -1178,12 +1250,22 @@ export default class DevServer extends Server {
     })
   }
 
-  protected getPagesManifest(): undefined {
-    return undefined
+  protected getPagesManifest(): PagesManifest | undefined {
+    return (
+      NodeManifestLoader.require(
+        pathJoin(this.serverDistDir, PAGES_MANIFEST)
+      ) ?? undefined
+    )
   }
 
-  protected getAppPathsManifest(): undefined {
-    return undefined
+  protected getAppPathsManifest(): PagesManifest | undefined {
+    if (!this.hasAppDir) return undefined
+
+    return (
+      NodeManifestLoader.require(
+        pathJoin(this.serverDistDir, APP_PATHS_MANIFEST)
+      ) ?? undefined
+    )
   }
 
   protected getMiddleware() {
@@ -1230,9 +1312,12 @@ export default class DevServer extends Server {
   generateRoutes() {
     const { fsRoutes, ...otherRoutes } = super.generateRoutes()
 
+    // Create a shallow copy so we can mutate it.
+    const routes = [...fsRoutes]
+
     // In development we expose all compiled files for react-error-overlay's line show feature
     // We use unshift so that we're sure the routes is defined before Next's default routes
-    fsRoutes.unshift({
+    routes.unshift({
       match: getPathMatch('/_next/development/:path*'),
       type: 'route',
       name: '_next/development catchall',
@@ -1245,7 +1330,7 @@ export default class DevServer extends Server {
       },
     })
 
-    fsRoutes.unshift({
+    routes.unshift({
       match: getPathMatch(
         `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_CLIENT_PAGES_MANIFEST}`
       ),
@@ -1269,7 +1354,7 @@ export default class DevServer extends Server {
       },
     })
 
-    fsRoutes.unshift({
+    routes.unshift({
       match: getPathMatch(
         `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_MIDDLEWARE_MANIFEST}`
       ),
@@ -1285,7 +1370,7 @@ export default class DevServer extends Server {
       },
     })
 
-    fsRoutes.push({
+    routes.push({
       match: getPathMatch('/:path*'),
       type: 'route',
       name: 'catchall public directory route',
@@ -1308,7 +1393,7 @@ export default class DevServer extends Server {
       },
     })
 
-    return { fsRoutes, ...otherRoutes }
+    return { fsRoutes: routes, ...otherRoutes }
   }
 
   // In development public files are not added to the router but handled as a fallback instead
@@ -1316,11 +1401,6 @@ export default class DevServer extends Server {
     return []
   }
 
-  // In development dynamic routes cannot be known ahead of time
-  protected getDynamicRoutes(): never[] {
-    return []
-  }
-
   _filterAmpDevelopmentScript(
     html: string,
     event: { line: number; col: number; code: string }
@@ -1400,10 +1480,6 @@ export default class DevServer extends Server {
     }
   }
 
-  protected async ensureApiPage(pathname: string): Promise<void> {
-    return this.hotReloader!.ensurePage({ page: pathname, clientOnly: false })
-  }
-
   private persistPatchedGlobals(): void {
     this.originalFetch = global.fetch
   }
@@ -1417,13 +1493,15 @@ export default class DevServer extends Server {
     query,
     params,
     isAppPath,
-    appPaths,
+    appPaths = null,
+    shouldEnsure,
   }: {
     pathname: string
     query: ParsedUrlQuery
     params: Params
     isAppPath: boolean
     appPaths?: string[] | null
+    shouldEnsure: boolean
   }): Promise<FindComponentsResult | null> {
     await this.devReady
     const compilationErr = await this.getCompilationError(pathname)
@@ -1432,11 +1510,13 @@ export default class DevServer extends Server {
       throw new WrappedBuildError(compilationErr)
     }
     try {
-      await this.hotReloader!.ensurePage({
-        page: pathname,
-        appPaths,
-        clientOnly: false,
-      })
+      if (shouldEnsure || this.renderOpts.customServer) {
+        await this.hotReloader!.ensurePage({
+          page: pathname,
+          appPaths,
+          clientOnly: false,
+        })
+      }
 
       // When the new page is compiled, we need to reload the server component
       // manifest.
@@ -1503,7 +1583,7 @@ export default class DevServer extends Server {
     return errors[0]
   }
 
-  protected isServeableUrl(untrustedFileUrl: string): boolean {
+  protected isServableUrl(untrustedFileUrl: string): boolean {
     // This method mimics what the version of `send` we use does:
     // 1. decodeURIComponent:
     //    https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts
index 5299a4da7923a..643ae5b8dbadf 100644
--- a/packages/next/src/server/dev/on-demand-entry-handler.ts
+++ b/packages/next/src/server/dev/on-demand-entry-handler.ts
@@ -23,6 +23,9 @@ import {
   COMPILER_NAMES,
   RSC_MODULE_TYPES,
 } from '../../shared/lib/constants'
+import { RouteMatch } from '../future/route-matches/route-match'
+import { RouteKind } from '../future/route-kind'
+import { AppPageRouteMatch } from '../future/route-matches/app-page-route-match'
 
 const debug = origDebug('next:on-demand-entry-handler')
 
@@ -150,7 +153,7 @@ interface Entry extends EntryType {
    * All parallel pages that match the same entry, for example:
    * ['/parallel/@bar/nested/@a/page', '/parallel/@bar/nested/@b/page', '/parallel/@foo/nested/@a/page', '/parallel/@foo/nested/@b/page']
    */
-  appPaths: string[] | null
+  appPaths: ReadonlyArray<string> | null
 }
 
 interface ChildEntry extends EntryType {
@@ -331,7 +334,7 @@ async function findPagePathData(
 
       return {
         absolutePagePath: join(appDir, pagePath),
-        bundlePath: posix.join('app', normalizePagePath(pageUrl)),
+        bundlePath: posix.join('app', pageUrl),
         page: posix.normalize(pageUrl),
       }
     }
@@ -371,6 +374,27 @@ async function findPagePathData(
   }
 }
 
+async function findRoutePathData(
+  rootDir: string,
+  page: string,
+  extensions: string[],
+  pagesDir?: string,
+  appDir?: string,
+  match?: RouteMatch
+): ReturnType<typeof findPagePathData> {
+  if (match) {
+    // If the match is available, we don't have to discover the data from the
+    // filesystem.
+    return {
+      absolutePagePath: match.definition.filename,
+      page: match.definition.page,
+      bundlePath: match.definition.bundlePath,
+    }
+  }
+
+  return findPagePathData(rootDir, page, extensions, pagesDir, appDir)
+}
+
 export function onDemandEntryHandler({
   maxInactiveAge,
   multiCompiler,
@@ -558,10 +582,12 @@ export function onDemandEntryHandler({
       page,
       clientOnly,
       appPaths = null,
+      match,
     }: {
       page: string
       clientOnly: boolean
-      appPaths?: string[] | null
+      appPaths?: ReadonlyArray<string> | null
+      match?: RouteMatch
     }): Promise<void> {
       const stalledTime = 60
       const stalledEnsureTimeout = setTimeout(() => {
@@ -570,13 +596,21 @@ export function onDemandEntryHandler({
         )
       }, stalledTime * 1000)
 
+      // If the route is actually an app page route, then we should have access
+      // to the app route match, and therefore, the appPaths from it.
+      if (match?.definition.kind === RouteKind.APP_PAGE) {
+        const { definition: route } = match as AppPageRouteMatch
+        appPaths = route.appPaths
+      }
+
       try {
-        const pagePathData = await findPagePathData(
+        const pagePathData = await findRoutePathData(
           rootDir,
           page,
           nextConfig.pageExtensions,
           pagesDir,
-          appDir
+          appDir,
+          match
         )
 
         const isInsideAppDir =
diff --git a/packages/next/src/server/future/helpers/module-loader/module-loader.ts b/packages/next/src/server/future/helpers/module-loader/module-loader.ts
new file mode 100644
index 0000000000000..35376aebbb03b
--- /dev/null
+++ b/packages/next/src/server/future/helpers/module-loader/module-loader.ts
@@ -0,0 +1,6 @@
+/**
+ * Loads a given module for a given ID.
+ */
+export interface ModuleLoader {
+  load<M = any>(id: string): M
+}
diff --git a/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts b/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts
new file mode 100644
index 0000000000000..8218c6692544f
--- /dev/null
+++ b/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts
@@ -0,0 +1,10 @@
+import { ModuleLoader } from './module-loader'
+
+/**
+ * Loads a module using `require(id)`.
+ */
+export class NodeModuleLoader implements ModuleLoader {
+  public load<M>(id: string): M {
+    return require(id)
+  }
+}
diff --git a/packages/next/src/server/future/helpers/response-handlers.ts b/packages/next/src/server/future/helpers/response-handlers.ts
new file mode 100644
index 0000000000000..7fc2948e23ce8
--- /dev/null
+++ b/packages/next/src/server/future/helpers/response-handlers.ts
@@ -0,0 +1,37 @@
+export function handleTemporaryRedirectResponse(url: string): Response {
+  return new Response(null, {
+    status: 302,
+    statusText: 'Found',
+    headers: {
+      location: url,
+    },
+  })
+}
+
+export function handleBadRequestResponse(): Response {
+  return new Response(null, {
+    status: 400,
+    statusText: 'Bad Request',
+  })
+}
+
+export function handleNotFoundResponse(): Response {
+  return new Response(null, {
+    status: 404,
+    statusText: 'Not Found',
+  })
+}
+
+export function handleMethodNotAllowedResponse(): Response {
+  return new Response(null, {
+    status: 405,
+    statusText: 'Method Not Allowed',
+  })
+}
+
+export function handleInternalServerErrorResponse(): Response {
+  return new Response(null, {
+    status: 500,
+    statusText: 'Internal Server Error',
+  })
+}
diff --git a/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts
new file mode 100644
index 0000000000000..df253729b38c7
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts
@@ -0,0 +1,33 @@
+import { AbsoluteFilenameNormalizer } from './absolute-filename-normalizer'
+
+describe('AbsoluteFilenameNormalizer', () => {
+  it.each([
+    {
+      name: 'app',
+      pathname: '<root>/app/basic/(grouped)/endpoint/nested/route.ts',
+      expected: '/basic/(grouped)/endpoint/nested/route',
+    },
+    {
+      name: 'pages',
+      pathname: '<root>/pages/basic/endpoint/nested.ts',
+      expected: '/basic/endpoint/nested',
+    },
+    {
+      name: 'pages',
+      pathname: '<root>/pages/basic/endpoint/index.ts',
+      expected: '/basic/endpoint',
+    },
+  ])(
+    "normalizes '$pathname' to '$expected'",
+    ({ pathname, expected, name }) => {
+      const normalizer = new AbsoluteFilenameNormalizer(`<root>/${name}`, [
+        'ts',
+        'tsx',
+        'js',
+        'jsx',
+      ])
+
+      expect(normalizer.normalize(pathname)).toEqual(expected)
+    }
+  )
+})
diff --git a/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts
new file mode 100644
index 0000000000000..55aaf869bf508
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts
@@ -0,0 +1,32 @@
+import path from '../../../shared/lib/isomorphic/path'
+import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'
+import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep'
+import { removePagePathTail } from '../../../shared/lib/page-path/remove-page-path-tail'
+import { Normalizer } from './normalizer'
+
+/**
+ * Normalizes a given filename so that it's relative to the provided directory.
+ * It will also strip the extension (if provided) and the trailing `/index`.
+ */
+export class AbsoluteFilenameNormalizer implements Normalizer {
+  /**
+   *
+   * @param dir the directory for which the files should be made relative to
+   * @param extensions the extensions the file could have
+   * @param keepIndex when `true` the trailing `/index` is _not_ removed
+   */
+  constructor(
+    private readonly dir: string,
+    private readonly extensions: ReadonlyArray<string>
+  ) {}
+
+  public normalize(pathname: string): string {
+    return removePagePathTail(
+      normalizePathSep(ensureLeadingSlash(path.relative(this.dir, pathname))),
+      {
+        extensions: this.extensions,
+        keepIndex: false,
+      }
+    )
+  }
+}
diff --git a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts
new file mode 100644
index 0000000000000..323247501b04e
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts
@@ -0,0 +1,59 @@
+import { Normalizer } from './normalizer'
+
+export interface LocaleRouteNormalizer extends Normalizer {
+  readonly locales: ReadonlyArray<string>
+  readonly defaultLocale: string
+  match(
+    pathname: string,
+    options?: { inferDefaultLocale: boolean }
+  ): { detectedLocale?: string; pathname: string }
+}
+
+export class LocaleRouteNormalizer implements Normalizer {
+  private readonly lowerCase: ReadonlyArray<string>
+
+  constructor(
+    public readonly locales: ReadonlyArray<string>,
+    public readonly defaultLocale: string
+  ) {
+    this.lowerCase = locales.map((locale) => locale.toLowerCase())
+  }
+
+  public match(
+    pathname: string,
+    options?: { inferDefaultLocale: boolean }
+  ): {
+    pathname: string
+    detectedLocale?: string
+  } {
+    let detectedLocale: string | undefined = options?.inferDefaultLocale
+      ? this.defaultLocale
+      : undefined
+    if (this.locales.length === 0) return { detectedLocale, pathname }
+
+    // The first segment will be empty, because it has a leading `/`. If
+    // there is no further segment, there is no locale.
+    const segments = pathname.split('/')
+    if (!segments[1]) return { detectedLocale, pathname }
+
+    // The second segment will contain the locale part if any.
+    const segment = segments[1].toLowerCase()
+
+    // See if the segment matches one of the locales.
+    const index = this.lowerCase.indexOf(segment)
+    if (index < 0) return { detectedLocale, pathname }
+
+    // Return the case-sensitive locale.
+    detectedLocale = this.locales[index]
+
+    // Remove the `/${locale}` part of the pathname.
+    pathname = pathname.slice(detectedLocale.length + 1) || '/'
+
+    return { detectedLocale, pathname }
+  }
+
+  public normalize(pathname: string): string {
+    const match = this.match(pathname)
+    return match.pathname
+  }
+}
diff --git a/packages/next/src/server/future/normalizers/normalizer.ts b/packages/next/src/server/future/normalizers/normalizer.ts
new file mode 100644
index 0000000000000..98b6e06048acf
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/normalizer.ts
@@ -0,0 +1,3 @@
+export interface Normalizer {
+  normalize(pathname: string): string
+}
diff --git a/packages/next/src/server/future/normalizers/normalizers.ts b/packages/next/src/server/future/normalizers/normalizers.ts
new file mode 100644
index 0000000000000..11226a80daeb2
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/normalizers.ts
@@ -0,0 +1,20 @@
+import { Normalizer } from './normalizer'
+
+/**
+ * Normalizers combines many normalizers into a single normalizer interface that
+ * will normalize the inputted pathname with each normalizer in order.
+ */
+export class Normalizers implements Normalizer {
+  constructor(private readonly normalizers: Array<Normalizer> = []) {}
+
+  public push(normalizer: Normalizer) {
+    this.normalizers.push(normalizer)
+  }
+
+  public normalize(pathname: string): string {
+    return this.normalizers.reduce<string>(
+      (normalized, normalizer) => normalizer.normalize(normalized),
+      pathname
+    )
+  }
+}
diff --git a/packages/next/src/server/future/normalizers/prefixing-normalizer.ts b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts
new file mode 100644
index 0000000000000..e4dade43429c8
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts
@@ -0,0 +1,10 @@
+import path from 'path'
+import { Normalizer } from './normalizer'
+
+export class PrefixingNormalizer implements Normalizer {
+  constructor(private readonly prefix: string) {}
+
+  public normalize(pathname: string): string {
+    return path.posix.join(this.prefix, pathname)
+  }
+}
diff --git a/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts b/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts
new file mode 100644
index 0000000000000..e8b785661e2fa
--- /dev/null
+++ b/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts
@@ -0,0 +1,5 @@
+import { Normalizer } from './normalizer'
+
+export function wrapNormalizerFn(fn: (pathname: string) => string): Normalizer {
+  return { normalize: fn }
+}
diff --git a/packages/next/src/server/future/route-definitions/app-page-route-definition.ts b/packages/next/src/server/future/route-definitions/app-page-route-definition.ts
new file mode 100644
index 0000000000000..d76ea63a6f812
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/app-page-route-definition.ts
@@ -0,0 +1,7 @@
+import { RouteKind } from '../route-kind'
+import { RouteDefinition } from './route-definition'
+
+export interface AppPageRouteDefinition
+  extends RouteDefinition<RouteKind.APP_PAGE> {
+  readonly appPaths: ReadonlyArray<string>
+}
diff --git a/packages/next/src/server/future/route-definitions/app-route-route-definition.ts b/packages/next/src/server/future/route-definitions/app-route-route-definition.ts
new file mode 100644
index 0000000000000..f72e385dac743
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/app-route-route-definition.ts
@@ -0,0 +1,5 @@
+import { RouteKind } from '../route-kind'
+import { RouteDefinition } from './route-definition'
+
+export interface AppRouteRouteDefinition
+  extends RouteDefinition<RouteKind.APP_ROUTE> {}
diff --git a/packages/next/src/server/future/route-definitions/locale-route-definition.ts b/packages/next/src/server/future/route-definitions/locale-route-definition.ts
new file mode 100644
index 0000000000000..68978e506836e
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/locale-route-definition.ts
@@ -0,0 +1,17 @@
+import { RouteKind } from '../route-kind'
+import { RouteDefinition } from './route-definition'
+
+export interface LocaleRouteDefinition<K extends RouteKind = RouteKind>
+  extends RouteDefinition<K> {
+  /**
+   * When defined it means that this route is locale aware. When undefined,
+   * it means no special handling has to occur to process locales.
+   */
+  i18n?: {
+    /**
+     * Describes the locale for the route. If this is undefined, then it
+     * indicates that this route can handle _any_ locale.
+     */
+    locale?: string
+  }
+}
diff --git a/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts
new file mode 100644
index 0000000000000..9510b0419198a
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts
@@ -0,0 +1,5 @@
+import { RouteKind } from '../route-kind'
+import { LocaleRouteDefinition } from './locale-route-definition'
+
+export interface PagesAPIRouteDefinition
+  extends LocaleRouteDefinition<RouteKind.PAGES_API> {}
diff --git a/packages/next/src/server/future/route-definitions/pages-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-route-definition.ts
new file mode 100644
index 0000000000000..8e5055d8c0afa
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/pages-route-definition.ts
@@ -0,0 +1,5 @@
+import { RouteKind } from '../route-kind'
+import { LocaleRouteDefinition } from './locale-route-definition'
+
+export interface PagesRouteDefinition
+  extends LocaleRouteDefinition<RouteKind.PAGES> {}
diff --git a/packages/next/src/server/future/route-definitions/route-definition.ts b/packages/next/src/server/future/route-definitions/route-definition.ts
new file mode 100644
index 0000000000000..91602791178da
--- /dev/null
+++ b/packages/next/src/server/future/route-definitions/route-definition.ts
@@ -0,0 +1,9 @@
+import { RouteKind } from '../route-kind'
+
+export interface RouteDefinition<K extends RouteKind = RouteKind> {
+  readonly kind: K
+  readonly bundlePath: string
+  readonly filename: string
+  readonly page: string
+  readonly pathname: string
+}
diff --git a/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts
new file mode 100644
index 0000000000000..8df7e9a0e62d4
--- /dev/null
+++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts
@@ -0,0 +1,101 @@
+import { BaseNextRequest, BaseNextResponse } from '../../base-http'
+import { RouteKind } from '../route-kind'
+import { RouteMatch } from '../route-matches/route-match'
+import { RouteHandlerManager } from './route-handler-manager'
+
+const req = {} as BaseNextRequest
+const res = {} as BaseNextResponse
+
+describe('RouteHandlerManager', () => {
+  it('will return false when there are no handlers', async () => {
+    const handlers = new RouteHandlerManager()
+    expect(
+      await handlers.handle(
+        {
+          definition: {
+            kind: RouteKind.PAGES,
+            filename: '<root>/index.js',
+            pathname: '/',
+            bundlePath: '<bundle path>',
+            page: '<page>',
+          },
+        },
+        req,
+        res
+      )
+    ).toEqual(false)
+  })
+
+  it('will return false when there is no matching handler', async () => {
+    const handlers = new RouteHandlerManager()
+    const handler = { handle: jest.fn() }
+    handlers.set(RouteKind.APP_PAGE, handler)
+
+    expect(
+      await handlers.handle(
+        {
+          definition: {
+            kind: RouteKind.PAGES,
+            filename: '<root>/index.js',
+            pathname: '/',
+            bundlePath: '<bundle path>',
+            page: '<page>',
+          },
+        },
+        req,
+        res
+      )
+    ).toEqual(false)
+    expect(handler.handle).not.toHaveBeenCalled()
+  })
+
+  it('will return true when there is a matching handler', async () => {
+    const handlers = new RouteHandlerManager()
+    const handler = { handle: jest.fn() }
+    handlers.set(RouteKind.APP_PAGE, handler)
+
+    const route: RouteMatch = {
+      definition: {
+        kind: RouteKind.APP_PAGE,
+        filename: '<root>/index.js',
+        pathname: '/',
+        bundlePath: '<bundle path>',
+        page: '<page>',
+      },
+    }
+
+    expect(await handlers.handle(route, req, res)).toEqual(true)
+    expect(handler.handle).toHaveBeenCalledWith(route, req, res)
+  })
+
+  it('will throw when multiple handlers are added for the same type', () => {
+    const handlers = new RouteHandlerManager()
+    const handler = { handle: jest.fn() }
+    expect(() => handlers.set(RouteKind.APP_PAGE, handler)).not.toThrow()
+    expect(() => handlers.set(RouteKind.APP_ROUTE, handler)).not.toThrow()
+    expect(() => handlers.set(RouteKind.APP_PAGE, handler)).toThrow()
+    expect(() => handlers.set(RouteKind.APP_ROUTE, handler)).toThrow()
+  })
+
+  it('will call the correct handler', async () => {
+    const handlers = new RouteHandlerManager()
+    const goodHandler = { handle: jest.fn() }
+    const badHandler = { handle: jest.fn() }
+    handlers.set(RouteKind.APP_PAGE, goodHandler)
+    handlers.set(RouteKind.APP_ROUTE, badHandler)
+
+    const route: RouteMatch = {
+      definition: {
+        kind: RouteKind.APP_PAGE,
+        filename: '<root>/index.js',
+        pathname: '/',
+        bundlePath: '<bundle path>',
+        page: '<page>',
+      },
+    }
+
+    expect(await handlers.handle(route, req, res)).toEqual(true)
+    expect(goodHandler.handle).toBeCalledWith(route, req, res)
+    expect(badHandler.handle).not.toBeCalled()
+  })
+})
diff --git a/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts
new file mode 100644
index 0000000000000..b8a8b8df79018
--- /dev/null
+++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts
@@ -0,0 +1,36 @@
+import { BaseNextRequest, BaseNextResponse } from '../../base-http'
+import { RouteKind } from '../route-kind'
+import { RouteMatch } from '../route-matches/route-match'
+import { RouteDefinition } from '../route-definitions/route-definition'
+import { RouteHandler } from '../route-handlers/route-handler'
+
+export class RouteHandlerManager {
+  private readonly handlers: Partial<{
+    [K in RouteKind]: RouteHandler
+  }> = {}
+
+  public set<
+    K extends RouteKind,
+    D extends RouteDefinition<K>,
+    M extends RouteMatch<D>,
+    H extends RouteHandler<M>
+  >(kind: K, handler: H) {
+    if (kind in this.handlers) {
+      throw new Error('Invariant: duplicate route handler added for kind')
+    }
+
+    this.handlers[kind] = handler
+  }
+
+  public async handle(
+    match: RouteMatch,
+    req: BaseNextRequest,
+    res: BaseNextResponse
+  ): Promise<boolean> {
+    const handler = this.handlers[match.definition.kind]
+    if (!handler) return false
+
+    await handler.handle(match, req, res)
+    return true
+  }
+}
diff --git a/packages/next/src/server/future/route-handlers/app-page-route-handler.ts b/packages/next/src/server/future/route-handlers/app-page-route-handler.ts
new file mode 100644
index 0000000000000..3f0430d1fd6e6
--- /dev/null
+++ b/packages/next/src/server/future/route-handlers/app-page-route-handler.ts
@@ -0,0 +1,8 @@
+import { AppPageRouteMatch } from '../route-matches/app-page-route-match'
+import { RouteHandler } from './route-handler'
+
+export class AppPageRouteHandler implements RouteHandler<AppPageRouteMatch> {
+  public async handle(): Promise<void> {
+    throw new Error('Method not implemented.')
+  }
+}
diff --git a/packages/next/src/server/future/route-handlers/app-route-route-handler.ts b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts
new file mode 100644
index 0000000000000..210967447233f
--- /dev/null
+++ b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts
@@ -0,0 +1,316 @@
+import { isNotFoundError } from '../../../client/components/not-found'
+import {
+  getURLFromRedirectError,
+  isRedirectError,
+} from '../../../client/components/redirect'
+import type {
+  RequestAsyncStorage,
+  RequestStore,
+} from '../../../client/components/request-async-storage'
+import type { Params } from '../../../shared/lib/router/utils/route-matcher'
+import type { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper'
+import {
+  RequestAsyncStorageWrapper,
+  type RequestContext,
+} from '../../async-storage/request-async-storage-wrapper'
+import type { BaseNextRequest, BaseNextResponse } from '../../base-http'
+import type { NodeNextRequest, NodeNextResponse } from '../../base-http/node'
+import { getRequestMeta } from '../../request-meta'
+import {
+  handleBadRequestResponse,
+  handleInternalServerErrorResponse,
+  handleMethodNotAllowedResponse,
+  handleNotFoundResponse,
+  handleTemporaryRedirectResponse,
+} from '../helpers/response-handlers'
+import { AppRouteRouteMatch } from '../route-matches/app-route-route-match'
+import { HTTP_METHOD, isHTTPMethod } from '../../web/http'
+import { NextRequest } from '../../web/spec-extension/request'
+import { fromNodeHeaders } from '../../web/utils'
+import { ModuleLoader } from '../helpers/module-loader/module-loader'
+import { NodeModuleLoader } from '../helpers/module-loader/node-module-loader'
+import { RouteHandler } from './route-handler'
+import * as Log from '../../../build/output/log'
+
+/**
+ * Handler function for app routes.
+ */
+export type AppRouteHandlerFn = (
+  /**
+   * Incoming request object.
+   */
+  req: Request,
+  /**
+   * Context properties on the request (including the parameters if this was a
+   * dynamic route).
+   */
+  ctx: { params?: Params }
+) => Response
+
+/**
+ * AppRouteModule is the specific userland module that is exported. This will
+ * contain the HTTP methods that this route can respond to.
+ */
+export type AppRouteModule = {
+  /**
+   * Contains all the exported userland code.
+   */
+  handlers: Record<HTTP_METHOD, AppRouteHandlerFn>
+
+  /**
+   * The exported async storage object for this worker/module.
+   */
+  requestAsyncStorage: RequestAsyncStorage
+}
+
+/**
+ * Wraps the base next request to a request compatible with the app route
+ * signature.
+ *
+ * @param req base request to adapt for use with app routes
+ * @returns the wrapped request.
+ */
+function wrapRequest(req: BaseNextRequest): Request {
+  const { originalRequest } = req as NodeNextRequest
+
+  const url = getRequestMeta(originalRequest, '__NEXT_INIT_URL')
+  if (!url) throw new Error('Invariant: missing url on request')
+
+  // HEAD and GET requests can not have a body.
+  const body: BodyInit | null | undefined =
+    req.method !== 'GET' && req.method !== 'HEAD' && req.body ? req.body : null
+
+  return new NextRequest(url, {
+    body,
+    // @ts-expect-error - see https://github.com/whatwg/fetch/pull/1457
+    duplex: 'half',
+    method: req.method,
+    headers: fromNodeHeaders(req.headers),
+  })
+}
+
+function resolveHandlerError(err: any): Response {
+  if (isRedirectError(err)) {
+    const redirect = getURLFromRedirectError(err)
+    if (!redirect) {
+      throw new Error('Invariant: Unexpected redirect url format')
+    }
+
+    // This is a redirect error! Send the redirect response.
+    return handleTemporaryRedirectResponse(redirect)
+  }
+
+  if (isNotFoundError(err)) {
+    // This is a not found error! Send the not found response.
+    return handleNotFoundResponse()
+  }
+
+  // TODO: validate the correct handling behavior
+  Log.error(err)
+  return handleInternalServerErrorResponse()
+}
+
+async function sendResponse(
+  req: BaseNextRequest,
+  res: BaseNextResponse,
+  response: Response
+): Promise<void> {
+  // Copy over the response status.
+  res.statusCode = response.status
+  res.statusMessage = response.statusText
+
+  // Copy over the response headers.
+  response.headers.forEach((value, name) => {
+    // The append handling is special cased for `set-cookie`.
+    if (name.toLowerCase() === 'set-cookie') {
+      res.setHeader(name, value)
+    } else {
+      res.appendHeader(name, value)
+    }
+  })
+
+  /**
+   * The response can't be directly piped to the underlying response. The
+   * following is duplicated from the edge runtime handler.
+   *
+   * See packages/next/server/next-server.ts
+   */
+
+  const originalResponse = (res as NodeNextResponse).originalResponse
+
+  // A response body must not be sent for HEAD requests. See https://httpwg.org/specs/rfc9110.html#HEAD
+  if (response.body && req.method !== 'HEAD') {
+    const { consumeUint8ArrayReadableStream } =
+      require('next/dist/compiled/edge-runtime') as typeof import('next/dist/compiled/edge-runtime')
+    const iterator = consumeUint8ArrayReadableStream(response.body)
+    try {
+      for await (const chunk of iterator) {
+        originalResponse.write(chunk)
+      }
+    } finally {
+      originalResponse.end()
+    }
+  } else {
+    originalResponse.end()
+  }
+}
+
+export class AppRouteRouteHandler implements RouteHandler<AppRouteRouteMatch> {
+  constructor(
+    private readonly requestAsyncLocalStorageWrapper: AsyncStorageWrapper<
+      RequestStore,
+      RequestContext
+    > = new RequestAsyncStorageWrapper(),
+    private readonly moduleLoader: ModuleLoader = new NodeModuleLoader()
+  ) {}
+
+  private resolve(
+    req: BaseNextRequest,
+    mod: AppRouteModule
+  ): AppRouteHandlerFn {
+    // Ensure that the requested method is a valid method (to prevent RCE's).
+    if (!isHTTPMethod(req.method)) return handleBadRequestResponse
+
+    // Pull out the handlers from the app route module.
+    const { handlers } = mod
+
+    // Check to see if the requested method is available.
+    const handler: AppRouteHandlerFn | undefined = handlers[req.method]
+    if (handler) return handler
+
+    /**
+     * If the request got here, then it means that there was not a handler for
+     * the requested method. We'll try to automatically setup some methods if
+     * there's enough information to do so.
+     */
+
+    // If HEAD is not provided, but GET is, then we respond to HEAD using the
+    // GET handler without the body.
+    if (req.method === 'HEAD' && 'GET' in handlers) {
+      return handlers['GET']
+    }
+
+    // If OPTIONS is not provided then implement it.
+    if (req.method === 'OPTIONS') {
+      // TODO: check if HEAD is implemented, if so, use it to add more headers
+
+      // Get all the handler methods from the list of handlers.
+      const methods = Object.keys(handlers).filter((method) =>
+        isHTTPMethod(method)
+      ) as HTTP_METHOD[]
+
+      // If the list of methods doesn't include OPTIONS, add it, as it's
+      // automatically implemented.
+      if (!methods.includes('OPTIONS')) {
+        methods.push('OPTIONS')
+      }
+
+      // If the list of methods doesn't include HEAD, but it includes GET, then
+      // add HEAD as it's automatically implemented.
+      if (!methods.includes('HEAD') && methods.includes('GET')) {
+        methods.push('HEAD')
+      }
+
+      // Sort and join the list with commas to create the `Allow` header. See:
+      // https://httpwg.org/specs/rfc9110.html#field.allow
+      const allow = methods.sort().join(', ')
+
+      return () =>
+        new Response(null, { status: 204, headers: { Allow: allow } })
+    }
+
+    // A handler for the requested method was not found, so we should respond
+    // with the method not allowed handler.
+    return handleMethodNotAllowedResponse
+  }
+
+  private async execute(
+    { params }: AppRouteRouteMatch,
+    module: AppRouteModule,
+    req: BaseNextRequest,
+    res: BaseNextResponse
+  ): Promise<Response> {
+    // This is added by the webpack loader, we load it directly from the module.
+    const { requestAsyncStorage } = module
+
+    // Get the handler function for the given method.
+    const handle = this.resolve(req, module)
+
+    // Run the handler with the request AsyncLocalStorage to inject the helper
+    // support.
+    const response = await this.requestAsyncLocalStorageWrapper.wrap(
+      requestAsyncStorage,
+      {
+        req: (req as NodeNextRequest).originalRequest,
+        res: (res as NodeNextResponse).originalResponse,
+      },
+      () => handle(wrapRequest(req), { params })
+    )
+
+    // If the handler did't return a valid response, then return the internal
+    // error response.
+    if (!(response instanceof Response)) {
+      // TODO: validate the correct handling behavior, maybe log something?
+      return handleInternalServerErrorResponse()
+    }
+
+    if (response.headers.has('x-middleware-rewrite')) {
+      // TODO: move this error into the `NextResponse.rewrite()` function.
+      // TODO-APP: re-enable support below when we can proxy these type of requests
+      throw new Error(
+        'NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.'
+      )
+
+      // // This is a rewrite created via `NextResponse.rewrite()`. We need to send
+      // // the response up so it can be handled by the backing server.
+
+      // // If the server is running in minimal mode, we just want to forward the
+      // // response (including the rewrite headers) upstream so it can perform the
+      // // redirect for us, otherwise return with the special condition so this
+      // // server can perform a rewrite.
+      // if (!minimalMode) {
+      //   return { response, condition: 'rewrite' }
+      // }
+
+      // // Relativize the url so it's relative to the base url. This is so the
+      // // outgoing headers upstream can be relative.
+      // const rewritePath = response.headers.get('x-middleware-rewrite')!
+      // const initUrl = getRequestMeta(req, '__NEXT_INIT_URL')!
+      // const { pathname } = parseUrl(relativizeURL(rewritePath, initUrl))
+      // response.headers.set('x-middleware-rewrite', pathname)
+    }
+
+    if (response.headers.get('x-middleware-next') === '1') {
+      // TODO: move this error into the `NextResponse.next()` function.
+      throw new Error(
+        'NextResponse.next() was used in a app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler'
+      )
+    }
+
+    return response
+  }
+
+  public async handle(
+    match: AppRouteRouteMatch,
+    req: BaseNextRequest,
+    res: BaseNextResponse
+  ): Promise<void> {
+    try {
+      // Load the module using the module loader.
+      const module: AppRouteModule = await this.moduleLoader.load(
+        match.definition.filename
+      )
+
+      // TODO: patch fetch
+
+      // Execute the route to get the response.
+      const response = await this.execute(match, module, req, res)
+
+      // Send the response back to the response.
+      await sendResponse(req, res, response)
+    } catch (err) {
+      // Get the correct response based on the error.
+      await sendResponse(req, res, resolveHandlerError(err))
+    }
+  }
+}
diff --git a/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts b/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts
new file mode 100644
index 0000000000000..7887161394d8d
--- /dev/null
+++ b/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts
@@ -0,0 +1,8 @@
+import { PagesAPIRouteMatch } from '../route-matches/pages-api-route-match'
+import { RouteHandler } from './route-handler'
+
+export class PagesAPIRouteHandler implements RouteHandler<PagesAPIRouteMatch> {
+  public async handle(): Promise<void> {
+    throw new Error('Method not implemented.')
+  }
+}
diff --git a/packages/next/src/server/future/route-handlers/pages-route-handler.ts b/packages/next/src/server/future/route-handlers/pages-route-handler.ts
new file mode 100644
index 0000000000000..f33efb0850f4a
--- /dev/null
+++ b/packages/next/src/server/future/route-handlers/pages-route-handler.ts
@@ -0,0 +1,8 @@
+import { PagesRouteMatch } from '../route-matches/pages-route-match'
+import { RouteHandler } from './route-handler'
+
+export class PagesRouteHandler implements RouteHandler<PagesRouteMatch> {
+  public async handle(): Promise<void> {
+    throw new Error('Method not implemented.')
+  }
+}
diff --git a/packages/next/src/server/future/route-handlers/route-handler.ts b/packages/next/src/server/future/route-handlers/route-handler.ts
new file mode 100644
index 0000000000000..7a553fd872a2d
--- /dev/null
+++ b/packages/next/src/server/future/route-handlers/route-handler.ts
@@ -0,0 +1,6 @@
+import { BaseNextRequest, BaseNextResponse } from '../../base-http'
+import { RouteMatch } from '../route-matches/route-match'
+
+export interface RouteHandler<M extends RouteMatch = RouteMatch> {
+  handle(match: M, req: BaseNextRequest, res: BaseNextResponse): Promise<void>
+}
diff --git a/packages/next/src/server/future/route-kind.ts b/packages/next/src/server/future/route-kind.ts
new file mode 100644
index 0000000000000..25ede65e520bf
--- /dev/null
+++ b/packages/next/src/server/future/route-kind.ts
@@ -0,0 +1,20 @@
+export const enum RouteKind {
+  /**
+   * `PAGES` represents all the React pages that are under `pages/`.
+   */
+  PAGES,
+  /**
+   * `PAGES_API` represents all the API routes under `pages/api/`.
+   */
+  PAGES_API,
+  /**
+   * `APP_PAGE` represents all the React pages that are under `app/` with the
+   * filename of `page.{j,t}s{,x}`.
+   */
+  APP_PAGE,
+  /**
+   * `APP_ROUTE` represents all the API routes that are under `app/` with the
+   * filename of `route.{j,t}s{,x}`.
+   */
+  APP_ROUTE,
+}
diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts
new file mode 100644
index 0000000000000..0346c1f23377d
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts
@@ -0,0 +1,283 @@
+import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition'
+import { PagesRouteDefinition } from '../route-definitions/pages-route-definition'
+import { RouteKind } from '../route-kind'
+import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider'
+import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher'
+import { DefaultRouteMatcherManager } from './default-route-matcher-manager'
+import { MatchOptions } from './route-matcher-manager'
+
+describe('DefaultRouteMatcherManager', () => {
+  it('will throw an error when used before it has been reloaded', async () => {
+    const manager = new DefaultRouteMatcherManager()
+    await expect(manager.match('/some/not/real/path', {})).resolves.toEqual(
+      null
+    )
+    manager.push({ matchers: jest.fn(async () => []) })
+    await expect(manager.match('/some/not/real/path', {})).rejects.toThrow()
+    await manager.reload()
+    await expect(manager.match('/some/not/real/path', {})).resolves.toEqual(
+      null
+    )
+  })
+
+  it('will not error and not match when no matchers are provided', async () => {
+    const manager = new DefaultRouteMatcherManager()
+    await manager.reload()
+    await expect(manager.match('/some/not/real/path', {})).resolves.toEqual(
+      null
+    )
+  })
+
+  it.each<{
+    pathname: string
+    options: MatchOptions
+    definition: LocaleRouteDefinition
+  }>([
+    {
+      pathname: '/nl-NL/some/path',
+      options: {
+        i18n: {
+          detectedLocale: 'nl-NL',
+          pathname: '/some/path',
+        },
+      },
+      definition: {
+        kind: RouteKind.PAGES,
+        filename: '',
+        bundlePath: '',
+        page: '',
+        pathname: '/some/path',
+        i18n: {
+          locale: 'nl-NL',
+        },
+      },
+    },
+    {
+      pathname: '/en-US/some/path',
+      options: {
+        i18n: {
+          detectedLocale: 'en-US',
+          pathname: '/some/path',
+        },
+      },
+      definition: {
+        kind: RouteKind.PAGES,
+        filename: '',
+        bundlePath: '',
+        page: '',
+        pathname: '/some/path',
+        i18n: {
+          locale: 'en-US',
+        },
+      },
+    },
+    {
+      pathname: '/some/path',
+      options: {
+        i18n: {
+          pathname: '/some/path',
+        },
+      },
+      definition: {
+        kind: RouteKind.PAGES,
+        filename: '',
+        bundlePath: '',
+        page: '',
+        pathname: '/some/path',
+        i18n: {
+          locale: 'en-US',
+        },
+      },
+    },
+  ])(
+    'can handle locale aware matchers for $pathname and locale $options.i18n.detectedLocale',
+    async ({ pathname, options, definition }) => {
+      const manager = new DefaultRouteMatcherManager()
+
+      const matcher = new LocaleRouteMatcher(definition)
+      const provider: RouteMatcherProvider = {
+        matchers: jest.fn(async () => [matcher]),
+      }
+      manager.push(provider)
+      await manager.reload()
+
+      const match = await manager.match(pathname, options)
+      expect(match?.definition).toBe(definition)
+    }
+  )
+
+  it('calls the locale route matcher when one is provided', async () => {
+    const manager = new DefaultRouteMatcherManager()
+    const definition: PagesRouteDefinition = {
+      kind: RouteKind.PAGES,
+      filename: '',
+      bundlePath: '',
+      page: '',
+      pathname: '/some/path',
+      i18n: {
+        locale: 'en-US',
+      },
+    }
+    const matcher = new LocaleRouteMatcher(definition)
+    const provider: RouteMatcherProvider = {
+      matchers: jest.fn(async () => [matcher]),
+    }
+    manager.push(provider)
+    await manager.reload()
+
+    const options = {
+      i18n: { detectedLocale: undefined, pathname: '/some/path' },
+    }
+    const match = await manager.match('/en-US/some/path', options)
+    expect(match?.definition).toBe(definition)
+  })
+})
+
+// TODO: port tests
+/* eslint-disable jest/no-commented-out-tests */
+
+// describe('DefaultRouteMatcherManager', () => {
+//     describe('static routes', () => {
+//       it.each([
+//         ['/some/static/route', '<root>/some/static/route.js'],
+//         ['/some/other/static/route', '<root>/some/other/static/route.js'],
+//       ])('will match %s to %s', async (pathname, filename) => {
+//         const matchers = new DefaultRouteMatcherManager()
+
+//         matchers.push({
+//           routes: async () => [
+//             {
+//               kind: RouteKind.APP_ROUTE,
+//               pathname: '/some/other/static/route',
+//               filename: '<root>/some/other/static/route.js',
+//               bundlePath: '<bundle path>',
+//               page: '<page>',
+//             },
+//             {
+//               kind: RouteKind.APP_ROUTE,
+//               pathname: '/some/static/route',
+//               filename: '<root>/some/static/route.js',
+//               bundlePath: '<bundle path>',
+//               page: '<page>',
+//             },
+//           ],
+//         })
+
+//         await matchers.compile()
+
+//         expect(await matchers.match(pathname)).toEqual({
+//           kind: RouteKind.APP_ROUTE,
+//           pathname,
+//           filename,
+//           bundlePath: '<bundle path>',
+//           page: '<page>',
+//         })
+//       })
+//     })
+
+//     describe('async generator', () => {
+//       it('will match', async () => {
+//         const matchers = new DefaultRouteMatcherManager()
+
+//         matchers.push({
+//           routes: async () => [
+//             {
+//               kind: RouteKind.APP_ROUTE,
+//               pathname: '/account/[[...slug]]',
+//               filename: '<root>/account/[[...slug]].js',
+//               bundlePath: '<bundle path>',
+//               page: '<page>',
+//             },
+//             {
+//               kind: RouteKind.APP_ROUTE,
+//               pathname: '/blog/[[...slug]]',
+//               filename: '<root>/blog/[[...slug]].js',
+//               bundlePath: '<bundle path>',
+//               page: '<page>',
+//             },
+//             {
+//               kind: RouteKind.APP_ROUTE,
+//               pathname: '/[[...optional]]',
+//               filename: '<root>/[[...optional]].js',
+//               bundlePath: '<bundle path>',
+//               page: '<page>',
+//             },
+//           ],
+//         })
+
+//         await matchers.compile()
+
+//         const matches: string[] = []
+
+//         for await (const match of matchers.each('/blog/some-other-path')) {
+//           matches.push(match.definition.filename)
+//         }
+
+//         expect(matches).toHaveLength(2)
+//         expect(matches[0]).toEqual('<root>/blog/[[...slug]].js')
+//         expect(matches[1]).toEqual('<root>/[[...optional]].js')
+//       })
+//     })
+
+//     describe('dynamic routes', () => {
+//       it.each([
+//         {
+//           pathname: '/users/123',
+//           route: {
+//             pathname: '/users/[id]',
+//             filename: '<root>/users/[id].js',
+//             params: { id: '123' },
+//           },
+//         },
+//         {
+//           pathname: '/account/123',
+//           route: {
+//             pathname: '/[...paths]',
+//             filename: '<root>/[...paths].js',
+//             params: { paths: ['account', '123'] },
+//           },
+//         },
+//         {
+//           pathname: '/dashboard/users/123',
+//           route: {
+//             pathname: '/[...paths]',
+//             filename: '<root>/[...paths].js',
+//             params: { paths: ['dashboard', 'users', '123'] },
+//           },
+//         },
+//       ])(
+//         "will match '$pathname' to '$route.filename'",
+//         async ({ pathname, route }) => {
+//           const matchers = new DefaultRouteMatcherManager()
+
+//           matchers.push({
+//             routes: async () => [
+//               {
+//                 kind: RouteKind.APP_ROUTE,
+//                 pathname: '/[...paths]',
+//                 filename: '<root>/[...paths].js',
+//                 bundlePath: '<bundle path>',
+//                 page: '<page>',
+//               },
+//               {
+//                 kind: RouteKind.APP_ROUTE,
+//                 pathname: '/users/[id]',
+//                 filename: '<root>/users/[id].js',
+//                 bundlePath: '<bundle path>',
+//                 page: '<page>',
+//               },
+//             ],
+//           })
+
+//           await matchers.compile()
+
+//           expect(await matchers.match(pathname)).toEqual({
+//             kind: RouteKind.APP_ROUTE,
+//             bundlePath: '<bundle path>',
+//             page: '<page>',
+//             ...route,
+//           })
+//         }
+//       )
+//     })
+//   })
diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts
new file mode 100644
index 0000000000000..b8d88976b936f
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts
@@ -0,0 +1,281 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+
+import { isDynamicRoute } from '../../../shared/lib/router/utils'
+import { RouteKind } from '../route-kind'
+import { RouteMatch } from '../route-matches/route-match'
+import { RouteDefinition } from '../route-definitions/route-definition'
+import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider'
+import { RouteMatcher } from '../route-matchers/route-matcher'
+import { MatchOptions, RouteMatcherManager } from './route-matcher-manager'
+import { getSortedRoutes } from '../../../shared/lib/router/utils'
+import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher'
+import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'
+
+interface RouteMatchers {
+  static: ReadonlyArray<RouteMatcher>
+  dynamic: ReadonlyArray<RouteMatcher>
+  duplicates: Record<string, ReadonlyArray<RouteMatcher>>
+}
+
+export class DefaultRouteMatcherManager implements RouteMatcherManager {
+  private readonly providers: Array<RouteMatcherProvider> = []
+  protected readonly matchers: RouteMatchers = {
+    static: [],
+    dynamic: [],
+    duplicates: {},
+  }
+  private lastCompilationID = this.compilationID
+
+  /**
+   * When this value changes, it indicates that a change has been introduced
+   * that requires recompilation.
+   */
+  private get compilationID() {
+    return this.providers.length
+  }
+
+  private waitTillReadyPromise?: Promise<void>
+  public waitTillReady(): Promise<void> {
+    return this.waitTillReadyPromise ?? Promise.resolve()
+  }
+
+  private previousMatchers: ReadonlyArray<RouteMatcher> = []
+  public async reload() {
+    let callbacks: { resolve: Function; reject: Function }
+    this.waitTillReadyPromise = new Promise((resolve, reject) => {
+      callbacks = { resolve, reject }
+    })
+
+    // Grab the compilation ID for this run, we'll verify it at the end to
+    // ensure that if any routes were added before reloading is finished that
+    // we error out.
+    const compilationID = this.compilationID
+
+    try {
+      // Collect all the matchers from each provider.
+      const matchers: Array<RouteMatcher> = []
+
+      // Get all the providers matchers.
+      const providersMatchers: ReadonlyArray<ReadonlyArray<RouteMatcher>> =
+        await Promise.all(this.providers.map((provider) => provider.matchers()))
+
+      // Use this to detect duplicate pathnames.
+      const all = new Map<string, RouteMatcher>()
+      const duplicates: Record<string, RouteMatcher[]> = {}
+      for (const providerMatchers of providersMatchers) {
+        for (const matcher of providerMatchers) {
+          // Test to see if the matcher being added is a duplicate.
+          const duplicate = all.get(matcher.definition.pathname)
+          if (duplicate) {
+            // This looks a little weird, but essentially if the pathname
+            // already exists in the duplicates map, then we got that array
+            // reference. Otherwise, we create a new array with the original
+            // duplicate first. Then we push the new matcher into the duplicate
+            // array, and reset it to the duplicates object (which may be a
+            // no-op if the pathname already existed in the duplicates object).
+            // Then we set the array of duplicates on both the original
+            // duplicate object and the new one, so we can keep them in sync.
+            // If a new duplicate is found, and it matches an existing pathname,
+            // the retrieval of the `other` will actually return the array
+            // reference used by all other duplicates. This is why ReadonlyArray
+            // is so important! Array's are always references!
+            const others = duplicates[matcher.definition.pathname] ?? [
+              duplicate,
+            ]
+            others.push(matcher)
+            duplicates[matcher.definition.pathname] = others
+
+            // Add duplicated details to each route.
+            duplicate.duplicated = others
+            matcher.duplicated = others
+
+            // TODO: see if we should error for duplicates in production?
+          }
+
+          matchers.push(matcher)
+
+          // Add the matcher's pathname to the set.
+          all.set(matcher.definition.pathname, matcher)
+        }
+      }
+
+      // Update the duplicate matchers. This is used in the development manager
+      // to warn about duplicates.
+      this.matchers.duplicates = duplicates
+
+      // If the cache is the same as what we just parsed, we can exit now. We
+      // can tell by using the `===` which compares object identity, which for
+      // the manifest matchers, will return the same matcher each time.
+      if (
+        this.previousMatchers.length === matchers.length &&
+        this.previousMatchers.every(
+          (cachedMatcher, index) => cachedMatcher === matchers[index]
+        )
+      ) {
+        return
+      }
+      this.previousMatchers = matchers
+
+      // For matchers that are for static routes, filter them now.
+      this.matchers.static = matchers.filter((matcher) => !matcher.isDynamic)
+
+      // For matchers that are for dynamic routes, filter them and sort them now.
+      const dynamic = matchers.filter((matcher) => matcher.isDynamic)
+
+      // As `getSortedRoutes` only takes an array of strings, we need to create
+      // a map of the pathnames (used for sorting) and the matchers. When we
+      // have locales, there may be multiple matches for the same pathname. To
+      // handle this, we keep a map of all the indexes (in `reference`) and
+      // merge them in later.
+
+      const reference = new Map<string, number[]>()
+      const pathnames = new Array<string>()
+      for (let index = 0; index < dynamic.length; index++) {
+        // Grab the pathname from the definition.
+        const pathname = dynamic[index].definition.pathname
+
+        // Grab the index in the dynamic array, push it into the reference.
+        const indexes = reference.get(pathname) ?? []
+        indexes.push(index)
+
+        // If this is the first one set it. If it isn't, we don't need to
+        // because pushing above on the array will mutate the array already
+        // stored there because array's are always a reference!
+        if (indexes.length === 1) reference.set(pathname, indexes)
+        // Otherwise, continue, we've already added this pathname before.
+        else continue
+
+        pathnames.push(pathname)
+      }
+
+      // Sort the array of pathnames.
+      const sorted = getSortedRoutes(pathnames)
+
+      // For each of the sorted pathnames, iterate over them, grabbing the list
+      // of indexes and merging them back into the new `sortedDynamicMatchers`
+      // array. The order of the same matching pathname doesn't matter because
+      // they will have other matching characteristics (like the locale) that
+      // is considered.
+      const sortedDynamicMatchers: Array<RouteMatcher> = []
+      for (const pathname of sorted) {
+        const indexes = reference.get(pathname)
+        if (!Array.isArray(indexes)) {
+          throw new Error('Invariant: expected to find identity in indexes map')
+        }
+
+        for (const index of indexes) sortedDynamicMatchers.push(dynamic[index])
+      }
+
+      this.matchers.dynamic = sortedDynamicMatchers
+
+      // This means that there was a new matcher pushed while we were waiting
+      if (this.compilationID !== compilationID) {
+        throw new Error(
+          'Invariant: expected compilation to finish before new matchers were added, possible missing await'
+        )
+      }
+    } catch (err) {
+      callbacks!.reject(err)
+    } finally {
+      // The compilation ID matched, so mark the complication as finished.
+      this.lastCompilationID = compilationID
+      callbacks!.resolve()
+    }
+  }
+
+  public push(provider: RouteMatcherProvider): void {
+    this.providers.push(provider)
+  }
+
+  public async test(pathname: string, options: MatchOptions): Promise<boolean> {
+    // See if there's a match for the pathname...
+    const match = await this.match(pathname, options)
+
+    // This default implementation only needs to check to see if there _was_ a
+    // match. The development matcher actually changes it's behavior by not
+    // recompiling the routes.
+    return match !== null
+  }
+
+  public async match(
+    pathname: string,
+    options: MatchOptions
+  ): Promise<RouteMatch<RouteDefinition<RouteKind>> | null> {
+    // "Iterate" over the match options. Once we found a single match, exit with
+    // it, otherwise return null below. If no match is found, the inner block
+    // won't be called.
+    for await (const match of this.matchAll(pathname, options)) {
+      return match
+    }
+
+    return null
+  }
+
+  /**
+   * This is a point for other managers to override to inject other checking
+   * behavior like duplicate route checking on a per-request basis.
+   *
+   * @param pathname the pathname to validate against
+   * @param matcher the matcher to validate/test with
+   * @returns the match if found
+   */
+  protected validate(
+    pathname: string,
+    matcher: RouteMatcher,
+    options: MatchOptions
+  ): RouteMatch | null {
+    if (matcher instanceof LocaleRouteMatcher) {
+      return matcher.match(pathname, options)
+    }
+
+    return matcher.match(pathname)
+  }
+
+  public async *matchAll(
+    pathname: string,
+    options: MatchOptions
+  ): AsyncGenerator<RouteMatch<RouteDefinition<RouteKind>>, null, undefined> {
+    // Guard against the matcher manager from being run before it needs to be
+    // recompiled. This was preferred to re-running the compilation here because
+    // it should be re-ran only when it changes. If a match is attempted before
+    // this is done, it indicates that there is a case where a provider is added
+    // before it was recompiled (an error). We also don't want to affect request
+    // times.
+    if (this.lastCompilationID !== this.compilationID) {
+      throw new Error(
+        'Invariant: expected routes to have been loaded before match'
+      )
+    }
+
+    // Ensure that path matching is done with a leading slash.
+    pathname = ensureLeadingSlash(pathname)
+
+    // If this pathname doesn't look like a dynamic route, and this pathname is
+    // listed in the normalized list of routes, then return it. This ensures
+    // that when a route like `/user/[id]` is encountered, it doesn't just match
+    // with the list of normalized routes.
+    if (!isDynamicRoute(pathname)) {
+      for (const matcher of this.matchers.static) {
+        const match = this.validate(pathname, matcher, options)
+        if (!match) continue
+
+        yield match
+      }
+    }
+
+    // If we should skip handling dynamic routes, exit now.
+    if (options?.skipDynamic) return null
+
+    // Loop over the dynamic matchers, yielding each match.
+    for (const matcher of this.matchers.dynamic) {
+      const match = this.validate(pathname, matcher, options)
+      if (!match) continue
+
+      yield match
+    }
+
+    // We tried direct matching against the pathname and against all the dynamic
+    // paths, so there was no match.
+    return null
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts
new file mode 100644
index 0000000000000..be1cdd87d2236
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts
@@ -0,0 +1,134 @@
+import { RouteKind } from '../route-kind'
+import { RouteMatch } from '../route-matches/route-match'
+import { RouteDefinition } from '../route-definitions/route-definition'
+import { DefaultRouteMatcherManager } from './default-route-matcher-manager'
+import { MatchOptions, RouteMatcherManager } from './route-matcher-manager'
+import path from '../../../shared/lib/isomorphic/path'
+import * as Log from '../../../build/output/log'
+import chalk from 'next/dist/compiled/chalk'
+import { RouteMatcher } from '../route-matchers/route-matcher'
+
+export interface RouteEnsurer {
+  ensure(match: RouteMatch): Promise<void>
+}
+
+export class DevRouteMatcherManager extends DefaultRouteMatcherManager {
+  constructor(
+    private readonly production: RouteMatcherManager,
+    private readonly ensurer: RouteEnsurer,
+    private readonly dir: string
+  ) {
+    super()
+  }
+
+  public async test(pathname: string, options: MatchOptions): Promise<boolean> {
+    // Try to find a match within the developer routes.
+    const match = await super.match(pathname, options)
+
+    // Return if the match wasn't null. Unlike the implementation of `match`
+    // which uses `matchAll` here, this does not call `ensure` on the match
+    // found via the development matches.
+    return match !== null
+  }
+
+  protected validate(
+    pathname: string,
+    matcher: RouteMatcher,
+    options: MatchOptions
+  ): RouteMatch | null {
+    const match = super.validate(pathname, matcher, options)
+
+    // If a match was found, check to see if there were any conflicting app or
+    // pages files.
+    // TODO: maybe expand this to _any_ duplicated routes instead?
+    if (
+      match &&
+      matcher.duplicated &&
+      matcher.duplicated.some(
+        (duplicate) =>
+          duplicate.definition.kind === RouteKind.APP_PAGE ||
+          duplicate.definition.kind === RouteKind.APP_ROUTE
+      ) &&
+      matcher.duplicated.some(
+        (duplicate) =>
+          duplicate.definition.kind === RouteKind.PAGES ||
+          duplicate.definition.kind === RouteKind.PAGES_API
+      )
+    ) {
+      throw new Error(
+        `Conflicting app and page file found: ${matcher.duplicated
+          // Sort the error output so that the app pages (starting with "app")
+          // are first.
+          .sort((a, b) =>
+            a.definition.filename.localeCompare(b.definition.filename)
+          )
+          .map(
+            (duplicate) =>
+              `"${path.relative(this.dir, duplicate.definition.filename)}"`
+          )
+          .join(' and ')}. Please remove one to continue.`
+      )
+    }
+
+    return match
+  }
+
+  public async *matchAll(
+    pathname: string,
+    options: MatchOptions
+  ): AsyncGenerator<RouteMatch<RouteDefinition<RouteKind>>, null, undefined> {
+    // Compile the development routes.
+    // TODO: we may want to only run this during testing, users won't be fast enough to require this many dir scans
+    await super.reload()
+
+    // Iterate over the development matches to see if one of them match the
+    // request path.
+    for await (const development of super.matchAll(pathname, options)) {
+      // We're here, which means that we haven't seen this match yet, so we
+      // should try to ensure it and recompile the production matcher.
+      await this.ensurer.ensure(development)
+      await this.production.reload()
+
+      // Iterate over the production matches again, this time we should be able
+      // to match it against the production matcher unless there's an error.
+      for await (const production of this.production.matchAll(
+        pathname,
+        options
+      )) {
+        yield production
+      }
+    }
+
+    // We tried direct matching against the pathname and against all the dynamic
+    // paths, so there was no match.
+    return null
+  }
+
+  public async reload(): Promise<void> {
+    // Compile the production routes again.
+    await this.production.reload()
+
+    // Compile the development routes.
+    await super.reload()
+
+    // Check for and warn of any duplicates.
+    for (const [pathname, matchers] of Object.entries(
+      this.matchers.duplicates
+    )) {
+      // We only want to warn about matchers resolving to the same path if their
+      // identities are different.
+      const identity = matchers[0].identity
+      if (matchers.slice(1).some((matcher) => matcher.identity !== identity)) {
+        continue
+      }
+
+      Log.warn(
+        `Duplicate page detected. ${matchers
+          .map((matcher) =>
+            chalk.cyan(path.relative(this.dir, matcher.definition.filename))
+          )
+          .join(' and ')} resolve to ${chalk.cyan(pathname)}`
+      )
+    }
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts
new file mode 100644
index 0000000000000..146cda52e17a4
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts
@@ -0,0 +1,57 @@
+import { RouteMatch } from '../route-matches/route-match'
+import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider'
+import { LocaleMatcherMatchOptions } from '../route-matchers/locale-route-matcher'
+
+export type MatchOptions = { skipDynamic?: boolean } & LocaleMatcherMatchOptions
+
+export interface RouteMatcherManager {
+  /**
+   * Returns a promise that resolves when the matcher manager has finished
+   * reloading.
+   */
+  waitTillReady(): Promise<void>
+
+  /**
+   * Pushes in a new matcher for this manager to manage. After all the
+   * providers have been pushed, the manager must be reloaded.
+   *
+   * @param provider the provider for this manager to also manage
+   */
+  push(provider: RouteMatcherProvider): void
+
+  /**
+   * Reloads the matchers from the providers. This should be done after all the
+   * providers have been added or the underlying providers should be refreshed.
+   */
+  reload(): Promise<void>
+
+  /**
+   * Tests the underlying matchers to find a match. It does not return the
+   * match.
+   *
+   * @param pathname the pathname to test for matches
+   * @param options the options for the testing
+   */
+  test(pathname: string, options: MatchOptions): Promise<boolean>
+
+  /**
+   * Returns the first match for a given request.
+   *
+   * @param pathname the pathname to match against
+   * @param options the options for the matching
+   */
+  match(pathname: string, options: MatchOptions): Promise<RouteMatch | null>
+
+  /**
+   * Returns a generator for each match for a given request. This should be
+   * consumed in a `for await (...)` loop, when finished, breaking or returning
+   * from the loop will terminate the matching operation.
+   *
+   * @param pathname the pathname to match against
+   * @param options the options for the matching
+   */
+  matchAll(
+    pathname: string,
+    options: MatchOptions
+  ): AsyncGenerator<RouteMatch, null, undefined>
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..bc3a15b4fac48
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts
@@ -0,0 +1,106 @@
+import { SERVER_DIRECTORY } from '../../../shared/lib/constants'
+import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition'
+import { RouteKind } from '../route-kind'
+import { AppPageRouteMatcherProvider } from './app-page-route-matcher-provider'
+import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader'
+
+describe('AppPageRouteMatcherProvider', () => {
+  it('returns no routes with an empty manifest', async () => {
+    const loader: ManifestLoader = { load: jest.fn(() => ({})) }
+    const matcher = new AppPageRouteMatcherProvider('<root>', loader)
+    await expect(matcher.matchers()).resolves.toEqual([])
+  })
+
+  describe('manifest matching', () => {
+    it.each<{
+      manifest: Record<string, string>
+      route: AppPageRouteDefinition
+    }>([
+      {
+        manifest: {
+          '/page': 'app/page.js',
+        },
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/',
+          filename: `<root>/${SERVER_DIRECTORY}/app/page.js`,
+          page: '/page',
+          bundlePath: 'app/page',
+          appPaths: ['/page'],
+        },
+      },
+      {
+        manifest: {
+          '/(marketing)/about/page': 'app/(marketing)/about/page.js',
+        },
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/about',
+          filename: `<root>/${SERVER_DIRECTORY}/app/(marketing)/about/page.js`,
+          page: '/(marketing)/about/page',
+          bundlePath: 'app/(marketing)/about/page',
+          appPaths: ['/(marketing)/about/page'],
+        },
+      },
+      {
+        manifest: {
+          '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js',
+        },
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/dashboard/users/[id]',
+          filename: `<root>/${SERVER_DIRECTORY}/app/dashboard/users/[id]/page.js`,
+          page: '/dashboard/users/[id]/page',
+          bundlePath: 'app/dashboard/users/[id]/page',
+          appPaths: ['/dashboard/users/[id]/page'],
+        },
+      },
+      {
+        manifest: { '/dashboard/users/page': 'app/dashboard/users/page.js' },
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/dashboard/users',
+          filename: `<root>/${SERVER_DIRECTORY}/app/dashboard/users/page.js`,
+          page: '/dashboard/users/page',
+          bundlePath: 'app/dashboard/users/page',
+          appPaths: ['/dashboard/users/page'],
+        },
+      },
+      {
+        manifest: {
+          '/dashboard/users/page': 'app/dashboard/users/page.js',
+          '/(marketing)/dashboard/users/page':
+            'app/(marketing)/dashboard/users/page.js',
+        },
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/dashboard/users',
+          filename: `<root>/${SERVER_DIRECTORY}/app/dashboard/users/page.js`,
+          page: '/dashboard/users/page',
+          bundlePath: 'app/dashboard/users/page',
+          appPaths: [
+            '/dashboard/users/page',
+            '/(marketing)/dashboard/users/page',
+          ],
+        },
+      },
+    ])(
+      'returns the correct routes for $route.pathname',
+      async ({ manifest, route }) => {
+        const loader: ManifestLoader = {
+          load: jest.fn(() => ({
+            '/users/[id]/route': 'app/users/[id]/route.js',
+            '/users/route': 'app/users/route.js',
+            ...manifest,
+          })),
+        }
+        const matcher = new AppPageRouteMatcherProvider('<root>', loader)
+        const matchers = await matcher.matchers()
+
+        expect(loader.load).toHaveBeenCalled()
+        expect(matchers).toHaveLength(1)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts
new file mode 100644
index 0000000000000..f379e38af3f23
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts
@@ -0,0 +1,63 @@
+import { isAppPageRoute } from '../../../lib/is-app-page-route'
+import {
+  APP_PATHS_MANIFEST,
+  SERVER_DIRECTORY,
+} from '../../../shared/lib/constants'
+import path from '../../../shared/lib/isomorphic/path'
+import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths'
+import { RouteKind } from '../route-kind'
+import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher'
+import {
+  Manifest,
+  ManifestLoader,
+} from './helpers/manifest-loaders/manifest-loader'
+import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider'
+
+export class AppPageRouteMatcherProvider extends ManifestRouteMatcherProvider<AppPageRouteMatcher> {
+  constructor(
+    private readonly distDir: string,
+    manifestLoader: ManifestLoader
+  ) {
+    super(APP_PATHS_MANIFEST, manifestLoader)
+  }
+
+  protected async transform(
+    manifest: Manifest
+  ): Promise<ReadonlyArray<AppPageRouteMatcher>> {
+    // This matcher only matches app pages.
+    const pages = Object.keys(manifest).filter((page) => isAppPageRoute(page))
+
+    // Collect all the app paths for each page. This could include any parallel
+    // routes.
+    const appPaths: Record<string, string[]> = {}
+    for (const page of pages) {
+      const pathname = normalizeAppPath(page)
+
+      if (pathname in appPaths) appPaths[pathname].push(page)
+      else appPaths[pathname] = [page]
+    }
+
+    // Format the routes.
+    const matchers: Array<AppPageRouteMatcher> = []
+    for (const [pathname, paths] of Object.entries(appPaths)) {
+      // TODO-APP: (wyattjoh) this is a hack right now, should be more deterministic
+      const page = paths[0]
+
+      const filename = path.join(this.distDir, SERVER_DIRECTORY, manifest[page])
+      const bundlePath = path.join('app', page)
+
+      matchers.push(
+        new AppPageRouteMatcher({
+          kind: RouteKind.APP_PAGE,
+          pathname,
+          page,
+          bundlePath,
+          filename,
+          appPaths: paths,
+        })
+      )
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..335322111f17e
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts
@@ -0,0 +1,69 @@
+import { SERVER_DIRECTORY } from '../../../shared/lib/constants'
+import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition'
+import { RouteKind } from '../route-kind'
+import { AppRouteRouteMatcherProvider } from './app-route-route-matcher-provider'
+import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader'
+
+describe('AppRouteRouteMatcherProvider', () => {
+  it('returns no routes with an empty manifest', async () => {
+    const loader: ManifestLoader = { load: jest.fn(() => ({})) }
+    const provider = new AppRouteRouteMatcherProvider('<root>', loader)
+    expect(await provider.matchers()).toEqual([])
+  })
+
+  describe('manifest matching', () => {
+    it.each<{
+      manifest: Record<string, string>
+      route: AppRouteRouteDefinition
+    }>([
+      {
+        manifest: {
+          '/route': 'app/route.js',
+        },
+        route: {
+          kind: RouteKind.APP_ROUTE,
+          pathname: '/',
+          filename: `<root>/${SERVER_DIRECTORY}/app/route.js`,
+          page: '/route',
+          bundlePath: 'app/route',
+        },
+      },
+      {
+        manifest: { '/users/[id]/route': 'app/users/[id]/route.js' },
+        route: {
+          kind: RouteKind.APP_ROUTE,
+          pathname: '/users/[id]',
+          filename: `<root>/${SERVER_DIRECTORY}/app/users/[id]/route.js`,
+          page: '/users/[id]/route',
+          bundlePath: 'app/users/[id]/route',
+        },
+      },
+      {
+        manifest: { '/users/route': 'app/users/route.js' },
+        route: {
+          kind: RouteKind.APP_ROUTE,
+          pathname: '/users',
+          filename: `<root>/${SERVER_DIRECTORY}/app/users/route.js`,
+          page: '/users/route',
+          bundlePath: 'app/users/route',
+        },
+      },
+    ])(
+      'returns the correct routes for $route.pathname',
+      async ({ manifest, route }) => {
+        const loader: ManifestLoader = {
+          load: jest.fn(() => ({
+            '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js',
+            '/dashboard/users/page': 'app/dashboard/users/page.js',
+            ...manifest,
+          })),
+        }
+        const provider = new AppRouteRouteMatcherProvider('<root>', loader)
+        const matchers = await provider.matchers()
+
+        expect(matchers).toHaveLength(1)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts
new file mode 100644
index 0000000000000..da2d2cdd1677d
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts
@@ -0,0 +1,50 @@
+import path from '../../../shared/lib/isomorphic/path'
+import { isAppRouteRoute } from '../../../lib/is-app-route-route'
+import {
+  APP_PATHS_MANIFEST,
+  SERVER_DIRECTORY,
+} from '../../../shared/lib/constants'
+import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths'
+import { RouteKind } from '../route-kind'
+import { AppRouteRouteMatcher } from '../route-matchers/app-route-route-matcher'
+import {
+  Manifest,
+  ManifestLoader,
+} from './helpers/manifest-loaders/manifest-loader'
+import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider'
+
+export class AppRouteRouteMatcherProvider extends ManifestRouteMatcherProvider<AppRouteRouteMatcher> {
+  constructor(
+    private readonly distDir: string,
+    manifestLoader: ManifestLoader
+  ) {
+    super(APP_PATHS_MANIFEST, manifestLoader)
+  }
+
+  protected async transform(
+    manifest: Manifest
+  ): Promise<ReadonlyArray<AppRouteRouteMatcher>> {
+    // This matcher only matches app routes.
+    const pages = Object.keys(manifest).filter((page) => isAppRouteRoute(page))
+
+    // Format the routes.
+    const matchers: Array<AppRouteRouteMatcher> = []
+    for (const page of pages) {
+      const pathname = normalizeAppPath(page)
+      const filename = path.join(this.distDir, SERVER_DIRECTORY, manifest[page])
+      const bundlePath = path.join('app', page)
+
+      matchers.push(
+        new AppRouteRouteMatcher({
+          kind: RouteKind.APP_ROUTE,
+          pathname,
+          page,
+          bundlePath,
+          filename,
+        })
+      )
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..2226b565d075e
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts
@@ -0,0 +1,89 @@
+import { AppPageRouteDefinition } from '../../route-definitions/app-page-route-definition'
+import { RouteKind } from '../../route-kind'
+import { DevAppPageRouteMatcherProvider } from './dev-app-page-route-matcher-provider'
+import { FileReader } from './helpers/file-reader/file-reader'
+
+describe('DevAppPageRouteMatcher', () => {
+  const dir = '<root>'
+  const extensions = ['ts', 'tsx', 'js', 'jsx']
+
+  it('returns no routes with an empty filesystem', async () => {
+    const reader: FileReader = { read: jest.fn(() => []) }
+    const provider = new DevAppPageRouteMatcherProvider(dir, extensions, reader)
+    const matchers = await provider.matchers()
+    expect(matchers).toHaveLength(0)
+    expect(reader.read).toBeCalledWith(dir)
+  })
+
+  describe('filename matching', () => {
+    it.each<{
+      files: ReadonlyArray<string>
+      route: AppPageRouteDefinition
+    }>([
+      {
+        files: [`${dir}/(marketing)/about/page.ts`],
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/about',
+          filename: `${dir}/(marketing)/about/page.ts`,
+          page: '/(marketing)/about/page',
+          bundlePath: 'app/(marketing)/about/page',
+          appPaths: ['/(marketing)/about/page'],
+        },
+      },
+      {
+        files: [`${dir}/(marketing)/about/page.ts`],
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/about',
+          filename: `${dir}/(marketing)/about/page.ts`,
+          page: '/(marketing)/about/page',
+          bundlePath: 'app/(marketing)/about/page',
+          appPaths: ['/(marketing)/about/page'],
+        },
+      },
+      {
+        files: [`${dir}/some/other/page.ts`],
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/some/other',
+          filename: `${dir}/some/other/page.ts`,
+          page: '/some/other/page',
+          bundlePath: 'app/some/other/page',
+          appPaths: ['/some/other/page'],
+        },
+      },
+      {
+        files: [`${dir}/page.ts`],
+        route: {
+          kind: RouteKind.APP_PAGE,
+          pathname: '/',
+          filename: `${dir}/page.ts`,
+          page: '/page',
+          bundlePath: 'app/page',
+          appPaths: ['/page'],
+        },
+      },
+    ])(
+      "matches the '$route.page' route specified with the provided files",
+      async ({ files, route }) => {
+        const reader: FileReader = {
+          read: jest.fn(() => [
+            ...extensions.map((ext) => `${dir}/some/route.${ext}`),
+            ...extensions.map((ext) => `${dir}/api/other.${ext}`),
+            ...files,
+          ]),
+        }
+        const provider = new DevAppPageRouteMatcherProvider(
+          dir,
+          extensions,
+          reader
+        )
+        const matchers = await provider.matchers()
+        expect(matchers).toHaveLength(1)
+        expect(reader.read).toBeCalledWith(dir)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts
new file mode 100644
index 0000000000000..e5a19adb23087
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts
@@ -0,0 +1,97 @@
+import { FileReader } from './helpers/file-reader/file-reader'
+import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher'
+import { Normalizer } from '../../normalizers/normalizer'
+import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer'
+import { Normalizers } from '../../normalizers/normalizers'
+import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn'
+import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths'
+import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer'
+import { RouteKind } from '../../route-kind'
+import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider'
+
+export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvider<AppPageRouteMatcher> {
+  private readonly expression: RegExp
+  private readonly normalizers: {
+    page: Normalizer
+    pathname: Normalizer
+    bundlePath: Normalizer
+  }
+
+  constructor(
+    appDir: string,
+    extensions: ReadonlyArray<string>,
+    reader: FileReader
+  ) {
+    super(appDir, reader)
+
+    // Match any page file that ends with `/page.${extension}` under the app
+    // directory.
+    this.expression = new RegExp(`\\/page\\.(?:${extensions.join('|')})$`)
+
+    const pageNormalizer = new AbsoluteFilenameNormalizer(appDir, extensions)
+
+    this.normalizers = {
+      page: pageNormalizer,
+      pathname: new Normalizers([
+        pageNormalizer,
+        // The pathname to match should have the trailing `/page` and other route
+        // group information stripped from it.
+        wrapNormalizerFn(normalizeAppPath),
+      ]),
+      bundlePath: new Normalizers([
+        pageNormalizer,
+        // Prefix the bundle path with `app/`.
+        new PrefixingNormalizer('app'),
+      ]),
+    }
+  }
+
+  protected async transform(
+    files: ReadonlyArray<string>
+  ): Promise<ReadonlyArray<AppPageRouteMatcher>> {
+    // Collect all the app paths for each page. This could include any parallel
+    // routes.
+    const cache = new Map<
+      string,
+      { page: string; pathname: string; bundlePath: string }
+    >()
+    const appPaths: Record<string, string[]> = {}
+    for (const filename of files) {
+      const page = this.normalizers.page.normalize(filename)
+      const pathname = this.normalizers.pathname.normalize(filename)
+      const bundlePath = this.normalizers.bundlePath.normalize(filename)
+
+      // Save the normalization results.
+      cache.set(filename, { page, pathname, bundlePath })
+
+      if (pathname in appPaths) appPaths[pathname].push(page)
+      else appPaths[pathname] = [page]
+    }
+
+    const matchers: Array<AppPageRouteMatcher> = []
+    for (const filename of files) {
+      // If the file isn't a match for this matcher, then skip it.
+      if (!this.expression.test(filename)) continue
+
+      // Grab the cached values (and the appPaths).
+      const cached = cache.get(filename)
+      if (!cached) {
+        throw new Error('Invariant: expected filename to exist in cache')
+      }
+      const { pathname, page, bundlePath } = cached
+
+      matchers.push(
+        new AppPageRouteMatcher({
+          kind: RouteKind.APP_PAGE,
+          pathname,
+          page,
+          bundlePath,
+          filename,
+          appPaths: appPaths[pathname],
+        })
+      )
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..477b22400b65a
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts
@@ -0,0 +1,65 @@
+import { AppRouteRouteDefinition } from '../../route-definitions/app-route-route-definition'
+import { RouteKind } from '../../route-kind'
+import { DevAppRouteRouteMatcherProvider } from './dev-app-route-route-matcher-provider'
+import { FileReader } from './helpers/file-reader/file-reader'
+
+describe('DevAppRouteRouteMatcher', () => {
+  const dir = '<root>'
+  const extensions = ['ts', 'tsx', 'js', 'jsx']
+
+  it('returns no routes with an empty filesystem', async () => {
+    const reader: FileReader = { read: jest.fn(() => []) }
+    const matcher = new DevAppRouteRouteMatcherProvider(dir, extensions, reader)
+    const matchers = await matcher.matchers()
+    expect(matchers).toHaveLength(0)
+    expect(reader.read).toBeCalledWith(dir)
+  })
+
+  describe('filename matching', () => {
+    it.each<{
+      files: ReadonlyArray<string>
+      route: AppRouteRouteDefinition
+    }>([
+      {
+        files: [`${dir}/some/other/route.ts`],
+        route: {
+          kind: RouteKind.APP_ROUTE,
+          pathname: '/some/other',
+          filename: `${dir}/some/other/route.ts`,
+          page: '/some/other/route',
+          bundlePath: 'app/some/other/route',
+        },
+      },
+      {
+        files: [`${dir}/route.ts`],
+        route: {
+          kind: RouteKind.APP_ROUTE,
+          pathname: '/',
+          filename: `${dir}/route.ts`,
+          page: '/route',
+          bundlePath: 'app/route',
+        },
+      },
+    ])(
+      "matches the '$route.page' route specified with the provided files",
+      async ({ files, route }) => {
+        const reader: FileReader = {
+          read: jest.fn(() => [
+            ...extensions.map((ext) => `${dir}/some/page.${ext}`),
+            ...extensions.map((ext) => `${dir}/api/other.${ext}`),
+            ...files,
+          ]),
+        }
+        const matcher = new DevAppRouteRouteMatcherProvider(
+          dir,
+          extensions,
+          reader
+        )
+        const matchers = await matcher.matchers()
+        expect(matchers).toHaveLength(1)
+        expect(reader.read).toBeCalledWith(dir)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts
new file mode 100644
index 0000000000000..d40b677a781c6
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts
@@ -0,0 +1,74 @@
+import { FileReader } from './helpers/file-reader/file-reader'
+import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher'
+import { Normalizer } from '../../normalizers/normalizer'
+import { Normalizers } from '../../normalizers/normalizers'
+import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer'
+import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn'
+import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths'
+import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer'
+import { RouteKind } from '../../route-kind'
+import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider'
+
+export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvider<AppRouteRouteMatcher> {
+  private readonly expression: RegExp
+  private readonly normalizers: {
+    page: Normalizer
+    pathname: Normalizer
+    bundlePath: Normalizer
+  }
+
+  constructor(
+    appDir: string,
+    extensions: ReadonlyArray<string>,
+    reader: FileReader
+  ) {
+    super(appDir, reader)
+
+    // Match any route file that ends with `/route.${extension}` under the app
+    // directory.
+    this.expression = new RegExp(`\\/route\\.(?:${extensions.join('|')})$`)
+
+    const pageNormalizer = new AbsoluteFilenameNormalizer(appDir, extensions)
+
+    this.normalizers = {
+      page: pageNormalizer,
+      pathname: new Normalizers([
+        pageNormalizer,
+        // The pathname to match should have the trailing `/route` and other route
+        // group information stripped from it.
+        wrapNormalizerFn(normalizeAppPath),
+      ]),
+      bundlePath: new Normalizers([
+        pageNormalizer,
+        // Prefix the bundle path with `app/`.
+        new PrefixingNormalizer('app'),
+      ]),
+    }
+  }
+
+  protected async transform(
+    files: ReadonlyArray<string>
+  ): Promise<ReadonlyArray<AppRouteRouteMatcher>> {
+    const matchers: Array<AppRouteRouteMatcher> = []
+    for (const filename of files) {
+      // If the file isn't a match for this matcher, then skip it.
+      if (!this.expression.test(filename)) continue
+
+      const page = this.normalizers.page.normalize(filename)
+      const pathname = this.normalizers.pathname.normalize(filename)
+      const bundlePath = this.normalizers.bundlePath.normalize(filename)
+
+      matchers.push(
+        new AppRouteRouteMatcher({
+          kind: RouteKind.APP_ROUTE,
+          pathname,
+          page,
+          bundlePath,
+          filename,
+        })
+      )
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..993855c4fb89f
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts
@@ -0,0 +1,86 @@
+import { PagesAPIRouteDefinition } from '../../route-definitions/pages-api-route-definition'
+import { RouteKind } from '../../route-kind'
+import { DevPagesAPIRouteMatcherProvider } from './dev-pages-api-route-matcher-provider'
+import { FileReader } from './helpers/file-reader/file-reader'
+
+describe('DevPagesAPIRouteMatcherProvider', () => {
+  const dir = '<root>'
+  const extensions = ['ts', 'tsx', 'js', 'jsx']
+
+  it('returns no routes with an empty filesystem', async () => {
+    const reader: FileReader = { read: jest.fn(() => []) }
+    const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader)
+    const matchers = await matcher.matchers()
+    expect(matchers).toHaveLength(0)
+    expect(reader.read).toBeCalledWith(dir)
+  })
+
+  describe('filename matching', () => {
+    it.each<{
+      files: ReadonlyArray<string>
+      route: PagesAPIRouteDefinition
+    }>([
+      {
+        files: [`${dir}/api/other/route.ts`],
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api/other/route',
+          filename: `${dir}/api/other/route.ts`,
+          page: '/api/other/route',
+          bundlePath: 'pages/api/other/route',
+        },
+      },
+      {
+        files: [`${dir}/api/other/index.ts`],
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api/other',
+          filename: `${dir}/api/other/index.ts`,
+          page: '/api/other',
+          bundlePath: 'pages/api/other',
+        },
+      },
+      {
+        files: [`${dir}/api.ts`],
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api',
+          filename: `${dir}/api.ts`,
+          page: '/api',
+          bundlePath: 'pages/api',
+        },
+      },
+      {
+        files: [`${dir}/api/index.ts`],
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api',
+          filename: `${dir}/api/index.ts`,
+          page: '/api',
+          bundlePath: 'pages/api',
+        },
+      },
+    ])(
+      "matches the '$route.page' route specified with the provided files",
+      async ({ files, route }) => {
+        const reader: FileReader = {
+          read: jest.fn(() => [
+            ...extensions.map((ext) => `${dir}/some/other/page.${ext}`),
+            ...extensions.map((ext) => `${dir}/some/other/route.${ext}`),
+            `${dir}/some/api/route.ts`,
+            ...files,
+          ]),
+        }
+        const matcher = new DevPagesAPIRouteMatcherProvider(
+          dir,
+          extensions,
+          reader
+        )
+        const matchers = await matcher.matchers()
+        expect(matchers).toHaveLength(1)
+        expect(reader.read).toBeCalledWith(dir)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts
new file mode 100644
index 0000000000000..5ba1b186f4e15
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts
@@ -0,0 +1,114 @@
+import { Normalizer } from '../../normalizers/normalizer'
+import { FileReader } from './helpers/file-reader/file-reader'
+import {
+  PagesAPILocaleRouteMatcher,
+  PagesAPIRouteMatcher,
+} from '../../route-matchers/pages-api-route-matcher'
+import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer'
+import { Normalizers } from '../../normalizers/normalizers'
+import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn'
+import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-page-path'
+import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer'
+import { RouteKind } from '../../route-kind'
+import path from 'path'
+import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer'
+import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider'
+
+export class DevPagesAPIRouteMatcherProvider extends FileCacheRouteMatcherProvider<PagesAPIRouteMatcher> {
+  private readonly expression: RegExp
+  private readonly normalizers: {
+    page: Normalizer
+    pathname: Normalizer
+    bundlePath: Normalizer
+  }
+
+  constructor(
+    private readonly pagesDir: string,
+    private readonly extensions: ReadonlyArray<string>,
+    reader: FileReader,
+    private readonly localeNormalizer?: LocaleRouteNormalizer
+  ) {
+    super(pagesDir, reader)
+
+    // Match any route file that ends with `/${filename}.${extension}` under the
+    // pages directory.
+    this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`)
+
+    const pageNormalizer = new AbsoluteFilenameNormalizer(pagesDir, extensions)
+
+    const bundlePathNormalizer = new Normalizers([
+      pageNormalizer,
+      // If the bundle path would have ended in a `/`, add a `index` to it.
+      wrapNormalizerFn(normalizePagePath),
+      // Prefix the bundle path with `pages/`.
+      new PrefixingNormalizer('pages'),
+    ])
+
+    this.normalizers = {
+      page: pageNormalizer,
+      pathname: pageNormalizer,
+      bundlePath: bundlePathNormalizer,
+    }
+  }
+
+  private test(filename: string): boolean {
+    // If the file does not end in the correct extension it's not a match.
+    if (!this.expression.test(filename)) return false
+
+    // Pages API routes must exist in the pages directory with the `/api/`
+    // prefix. The pathnames being tested here though are the full filenames,
+    // so we need to include the pages directory.
+
+    // TODO: could path separator normalization be needed here?
+    if (filename.startsWith(path.join(this.pagesDir, '/api/'))) return true
+
+    for (const extension of this.extensions) {
+      // We can also match if we have `pages/api.${extension}`, so check to
+      // see if it's a match.
+      if (filename === path.join(this.pagesDir, `api.${extension}`)) {
+        return true
+      }
+    }
+
+    return false
+  }
+
+  protected async transform(
+    files: ReadonlyArray<string>
+  ): Promise<ReadonlyArray<PagesAPIRouteMatcher>> {
+    const matchers: Array<PagesAPIRouteMatcher> = []
+    for (const filename of files) {
+      // If the file isn't a match for this matcher, then skip it.
+      if (!this.test(filename)) continue
+
+      const pathname = this.normalizers.pathname.normalize(filename)
+      const page = this.normalizers.page.normalize(filename)
+      const bundlePath = this.normalizers.bundlePath.normalize(filename)
+
+      if (this.localeNormalizer) {
+        matchers.push(
+          new PagesAPILocaleRouteMatcher({
+            kind: RouteKind.PAGES_API,
+            pathname,
+            page,
+            bundlePath,
+            filename,
+            i18n: {},
+          })
+        )
+      } else {
+        matchers.push(
+          new PagesAPIRouteMatcher({
+            kind: RouteKind.PAGES_API,
+            pathname,
+            page,
+            bundlePath,
+            filename,
+          })
+        )
+      }
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..576cc7e52072d
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts
@@ -0,0 +1,84 @@
+import { PagesRouteDefinition } from '../../route-definitions/pages-route-definition'
+import { RouteKind } from '../../route-kind'
+import { DevPagesRouteMatcherProvider } from './dev-pages-route-matcher-provider'
+import { FileReader } from './helpers/file-reader/file-reader'
+
+describe('DevPagesRouteMatcherProvider', () => {
+  const dir = '<root>'
+  const extensions = ['ts', 'tsx', 'js', 'jsx']
+
+  it('returns no routes with an empty filesystem', async () => {
+    const reader: FileReader = { read: jest.fn(() => []) }
+    const matcher = new DevPagesRouteMatcherProvider(dir, extensions, reader)
+    const matchers = await matcher.matchers()
+    expect(matchers).toHaveLength(0)
+    expect(reader.read).toBeCalledWith(dir)
+  })
+
+  describe('filename matching', () => {
+    it.each<{
+      files: ReadonlyArray<string>
+      route: PagesRouteDefinition
+    }>([
+      {
+        files: [`${dir}/index.ts`],
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/',
+          filename: `${dir}/index.ts`,
+          page: '/',
+          bundlePath: 'pages/index',
+        },
+      },
+      {
+        files: [`${dir}/some/api/route.ts`],
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/some/api/route',
+          filename: `${dir}/some/api/route.ts`,
+          page: '/some/api/route',
+          bundlePath: 'pages/some/api/route',
+        },
+      },
+      {
+        files: [`${dir}/some/other/route/index.ts`],
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/some/other/route',
+          filename: `${dir}/some/other/route/index.ts`,
+          page: '/some/other/route',
+          bundlePath: 'pages/some/other/route',
+        },
+      },
+      {
+        files: [`${dir}/some/other/route/index/route.ts`],
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/some/other/route/index/route',
+          filename: `${dir}/some/other/route/index/route.ts`,
+          page: '/some/other/route/index/route',
+          bundlePath: 'pages/some/other/route/index/route',
+        },
+      },
+    ])(
+      "matches the '$route.page' route specified with the provided files",
+      async ({ files, route }) => {
+        const reader: FileReader = {
+          read: jest.fn(() => [
+            ...extensions.map((ext) => `${dir}/api/other/page.${ext}`),
+            ...files,
+          ]),
+        }
+        const matcher = new DevPagesRouteMatcherProvider(
+          dir,
+          extensions,
+          reader
+        )
+        const matchers = await matcher.matchers()
+        expect(matchers).toHaveLength(1)
+        expect(reader.read).toBeCalledWith(dir)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts
new file mode 100644
index 0000000000000..85d1fd3b7660c
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts
@@ -0,0 +1,112 @@
+import { Normalizer } from '../../normalizers/normalizer'
+import { FileReader } from './helpers/file-reader/file-reader'
+import {
+  PagesRouteMatcher,
+  PagesLocaleRouteMatcher,
+} from '../../route-matchers/pages-route-matcher'
+import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer'
+import { Normalizers } from '../../normalizers/normalizers'
+import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn'
+import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-page-path'
+import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer'
+import { RouteKind } from '../../route-kind'
+import path from 'path'
+import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer'
+import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider'
+
+export class DevPagesRouteMatcherProvider extends FileCacheRouteMatcherProvider<PagesRouteMatcher> {
+  private readonly expression: RegExp
+  private readonly normalizers: {
+    page: Normalizer
+    pathname: Normalizer
+    bundlePath: Normalizer
+  }
+
+  constructor(
+    private readonly pagesDir: string,
+    private readonly extensions: ReadonlyArray<string>,
+    reader: FileReader,
+    private readonly localeNormalizer?: LocaleRouteNormalizer
+  ) {
+    super(pagesDir, reader)
+
+    // Match any route file that ends with `/${filename}.${extension}` under the
+    // pages directory.
+    this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`)
+
+    const pageNormalizer = new AbsoluteFilenameNormalizer(pagesDir, extensions)
+
+    this.normalizers = {
+      page: pageNormalizer,
+      pathname: pageNormalizer,
+      bundlePath: new Normalizers([
+        pageNormalizer,
+        // If the bundle path would have ended in a `/`, add a `index` to it.
+        wrapNormalizerFn(normalizePagePath),
+        // Prefix the bundle path with `pages/`.
+        new PrefixingNormalizer('pages'),
+      ]),
+    }
+  }
+
+  private test(filename: string): boolean {
+    // If the file does not end in the correct extension it's not a match.
+    if (!this.expression.test(filename)) return false
+
+    // Pages routes must exist in the pages directory without the `/api/`
+    // prefix. The pathnames being tested here though are the full filenames,
+    // so we need to include the pages directory.
+
+    // TODO: could path separator normalization be needed here?
+    if (filename.startsWith(`${this.pagesDir}/api/`)) return false
+
+    for (const extension of this.extensions) {
+      // We can also match if we have `pages/api.${extension}`, so check to
+      // see if it's a match.
+      if (filename === path.join(this.pagesDir, `api.${extension}`)) {
+        return false
+      }
+    }
+
+    return true
+  }
+
+  protected async transform(
+    files: ReadonlyArray<string>
+  ): Promise<ReadonlyArray<PagesRouteMatcher>> {
+    const matchers: Array<PagesRouteMatcher> = []
+    for (const filename of files) {
+      // If the file isn't a match for this matcher, then skip it.
+      if (!this.test(filename)) continue
+
+      const pathname = this.normalizers.pathname.normalize(filename)
+      const page = this.normalizers.page.normalize(filename)
+      const bundlePath = this.normalizers.bundlePath.normalize(filename)
+
+      if (this.localeNormalizer) {
+        matchers.push(
+          new PagesLocaleRouteMatcher({
+            kind: RouteKind.PAGES,
+            pathname,
+            page,
+            bundlePath,
+            filename,
+            i18n: {},
+          })
+        )
+      } else {
+        matchers.push(
+          new PagesRouteMatcher({
+            kind: RouteKind.PAGES,
+            pathname,
+            page,
+            bundlePath,
+            filename,
+          })
+        )
+      }
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts
new file mode 100644
index 0000000000000..bc212ee825e58
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts
@@ -0,0 +1,26 @@
+import { RouteMatcher } from '../../route-matchers/route-matcher'
+import { CachedRouteMatcherProvider } from '../helpers/cached-route-matcher-provider'
+import { FileReader } from './helpers/file-reader/file-reader'
+
+/**
+ * This will memoize the matchers when the file contents are the same.
+ */
+export abstract class FileCacheRouteMatcherProvider<
+  M extends RouteMatcher = RouteMatcher
+> extends CachedRouteMatcherProvider<M, ReadonlyArray<string>> {
+  constructor(dir: string, reader: FileReader) {
+    super({
+      load: async () => reader.read(dir),
+      compare: (left, right) => {
+        if (left.length !== right.length) return false
+
+        // Assuming the file traversal order is deterministic...
+        for (let i = 0; i < left.length; i++) {
+          if (left[i] !== right[i]) return false
+        }
+
+        return true
+      },
+    })
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts
new file mode 100644
index 0000000000000..16f2aa32e54bb
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts
@@ -0,0 +1,66 @@
+import { CachedFileReader } from './cached-file-reader'
+import { FileReader } from './file-reader'
+
+describe('CachedFileReader', () => {
+  it('will only scan the filesystem a minimal amount of times', async () => {
+    const pages = ['1', '2', '3']
+    const app = ['4', '5', '6']
+
+    const reader: FileReader = {
+      read: jest.fn(async (directory: string) => {
+        switch (directory) {
+          case '<root>/pages':
+            return pages
+          case '<root>/app':
+            return app
+          default:
+            throw new Error('unexpected')
+        }
+      }),
+    }
+    const cached = new CachedFileReader(reader)
+
+    const results = await Promise.all([
+      cached.read('<root>/pages'),
+      cached.read('<root>/pages'),
+      cached.read('<root>/app'),
+      cached.read('<root>/app'),
+    ])
+
+    expect(reader.read).toBeCalledTimes(2)
+    expect(results).toHaveLength(4)
+    expect(results[0]).toBe(pages)
+    expect(results[1]).toBe(pages)
+    expect(results[2]).toBe(app)
+    expect(results[3]).toBe(app)
+  })
+
+  it('will send an error back only to the correct reader', async () => {
+    const resolved: string[] = []
+    const reader: FileReader = {
+      read: jest.fn(async (directory: string) => {
+        switch (directory) {
+          case 'reject':
+            throw new Error('rejected')
+          case 'resolve':
+            return resolved
+          default:
+            throw new Error('should not occur')
+        }
+      }),
+    }
+    const cached = new CachedFileReader(reader)
+
+    await Promise.all(
+      ['reject', 'resolve', 'reject', 'resolve'].map(async (directory) => {
+        if (directory === 'reject') {
+          await expect(cached.read(directory)).rejects.toThrowError('rejected')
+        } else {
+          await expect(cached.read(directory)).resolves.toEqual(resolved)
+        }
+      })
+    )
+
+    expect(reader.read).toBeCalledTimes(2)
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts
new file mode 100644
index 0000000000000..c9db00d97953b
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts
@@ -0,0 +1,125 @@
+import { FileReader } from './file-reader'
+
+interface CachedFileReaderBatch {
+  completed: boolean
+  directories: Array<string>
+  callbacks: Array<{
+    resolve: (value: ReadonlyArray<string>) => void
+    reject: (err: any) => void
+  }>
+}
+
+/**
+ * CachedFileReader will deduplicate requests made to the same folder structure
+ * to scan for files.
+ */
+export class CachedFileReader implements FileReader {
+  private batch?: CachedFileReaderBatch
+
+  constructor(private readonly reader: FileReader) {}
+
+  // This allows us to schedule the batches after all the promises associated
+  // with loading files.
+  private schedulePromise?: Promise<void>
+  private schedule(callback: Function) {
+    if (!this.schedulePromise) {
+      this.schedulePromise = Promise.resolve()
+    }
+    this.schedulePromise.then(() => {
+      process.nextTick(callback)
+    })
+  }
+
+  private getOrCreateBatch(): CachedFileReaderBatch {
+    // If there is an existing batch and it's not completed, then reuse it.
+    if (this.batch && !this.batch.completed) {
+      return this.batch
+    }
+
+    const batch: CachedFileReaderBatch = {
+      completed: false,
+      directories: [],
+      callbacks: [],
+    }
+
+    this.batch = batch
+
+    this.schedule(async () => {
+      batch.completed = true
+      if (batch.directories.length === 0) return
+
+      // Collect all the results for each of the directories. If any error
+      // occurs, send the results back to the loaders.
+      let values: ReadonlyArray<ReadonlyArray<string> | Error>
+      try {
+        values = await this.load(batch.directories)
+      } catch (err) {
+        // Reject all the callbacks.
+        for (const { reject } of batch.callbacks) {
+          reject(err)
+        }
+        return
+      }
+
+      // Loop over all the callbacks and send them their results.
+      for (let i = 0; i < batch.callbacks.length; i++) {
+        const value = values[i]
+        if (value instanceof Error) {
+          batch.callbacks[i].reject(value)
+        } else {
+          batch.callbacks[i].resolve(value)
+        }
+      }
+    })
+
+    return batch
+  }
+
+  private async load(
+    directories: ReadonlyArray<string>
+  ): Promise<ReadonlyArray<ReadonlyArray<string> | Error>> {
+    // Make a unique array of directories. This is what lets us de-duplicate
+    // loads for the same directory.
+    const unique = [...new Set(directories)]
+
+    const results = await Promise.all(
+      unique.map(async (directory) => {
+        let files: ReadonlyArray<string> | undefined
+        let error: Error | undefined
+        try {
+          files = await this.reader.read(directory)
+        } catch (err) {
+          if (err instanceof Error) error = err
+        }
+
+        return { directory, files, error }
+      })
+    )
+
+    return directories.map((directory) => {
+      const found = results.find((result) => result.directory === directory)
+      if (!found) return []
+
+      if (found.files) return found.files
+      if (found.error) return found.error
+
+      return []
+    })
+  }
+
+  public async read(dir: string): Promise<ReadonlyArray<string>> {
+    // Get or create a new file reading batch.
+    const batch = this.getOrCreateBatch()
+
+    // Push this directory into the batch to resolve.
+    batch.directories.push(dir)
+
+    // Push the promise handles into the batch (under the same index) so it can
+    // be resolved later when it's scheduled.
+    const promise = new Promise<ReadonlyArray<string>>((resolve, reject) => {
+      batch.callbacks.push({ resolve, reject })
+    })
+
+    return promise
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts
new file mode 100644
index 0000000000000..1372d198778d8
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts
@@ -0,0 +1,56 @@
+import { type Dirent } from 'fs'
+import fs from 'fs/promises'
+import path from 'path'
+import { FileReader } from './file-reader'
+
+export class DefaultFileReader implements FileReader {
+  public async read(dir: string): Promise<ReadonlyArray<string>> {
+    const pathnames: string[] = []
+
+    let directories: string[] = [dir]
+
+    while (directories.length > 0) {
+      // Load all the files in each directory at the same time.
+      const results = await Promise.all(
+        directories.map(async (directory) => {
+          let files: Dirent[]
+          try {
+            files = await fs.readdir(directory, { withFileTypes: true })
+          } catch (err: any) {
+            // This can only happen when the underlying directory was removed. If
+            // anything other than this error occurs, re-throw it.
+            if (err.code !== 'ENOENT') throw err
+
+            // The error occurred, so abandon reading this directory.
+            files = []
+          }
+
+          return { directory, files }
+        })
+      )
+
+      // Empty the directories, we'll fill it later if some of the files are
+      // directories.
+      directories = []
+
+      // For each result of directory scans...
+      for (const { files, directory } of results) {
+        // And for each file in it...
+        for (const file of files) {
+          // Handle each file.
+          const pathname = path.join(directory, file.name)
+
+          // If the file is a directory, then add it to the list of directories,
+          // they'll be scanned on a later pass.
+          if (file.isDirectory()) {
+            directories.push(pathname)
+          } else {
+            pathnames.push(pathname)
+          }
+        }
+      }
+    }
+
+    return pathnames
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts
new file mode 100644
index 0000000000000..ece4fd3d65cf8
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts
@@ -0,0 +1,8 @@
+export interface FileReader {
+  /**
+   * Reads the directory contents recursively.
+   *
+   * @param dir directory to read recursively from
+   */
+  read(dir: string): Promise<ReadonlyArray<string>> | ReadonlyArray<string>
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts
new file mode 100644
index 0000000000000..e5e27b4e991a9
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts
@@ -0,0 +1,40 @@
+import { RouteMatcher } from '../../route-matchers/route-matcher'
+import { RouteMatcherProvider } from '../route-matcher-provider'
+
+interface LoaderComparable<D> {
+  load(): Promise<D>
+  compare(left: D, right: D): boolean
+}
+
+/**
+ * This will memoize the matchers if the loaded data is comparable.
+ */
+export abstract class CachedRouteMatcherProvider<
+  M extends RouteMatcher = RouteMatcher,
+  D = any
+> implements RouteMatcherProvider<M>
+{
+  private data?: D
+  private cached: ReadonlyArray<M> = []
+
+  constructor(private readonly loader: LoaderComparable<D>) {}
+
+  protected abstract transform(data: D): Promise<ReadonlyArray<M>>
+
+  public async matchers(): Promise<readonly M[]> {
+    const data = await this.loader.load()
+    if (!data) return []
+
+    // Return the cached matchers if the data has not changed.
+    if (this.data && this.loader.compare(this.data, data)) return this.cached
+    this.data = data
+
+    // Transform the manifest into matchers.
+    const matchers = await this.transform(data)
+
+    // Cache the matchers.
+    this.cached = matchers
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts
new file mode 100644
index 0000000000000..dbaf73cffb9d1
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts
@@ -0,0 +1,5 @@
+export type Manifest = Record<string, string>
+
+export interface ManifestLoader {
+  load(name: string): Manifest | null
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts
new file mode 100644
index 0000000000000..a7bcd301439aa
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts
@@ -0,0 +1,21 @@
+import { SERVER_DIRECTORY } from '../../../../../shared/lib/constants'
+import path from '../../../../../shared/lib/isomorphic/path'
+import { Manifest, ManifestLoader } from './manifest-loader'
+
+export class NodeManifestLoader implements ManifestLoader {
+  constructor(private readonly distDir: string) {}
+
+  static require(id: string) {
+    try {
+      return require(id)
+    } catch {
+      return null
+    }
+  }
+
+  public load(name: string): Manifest | null {
+    return NodeManifestLoader.require(
+      path.join(this.distDir, SERVER_DIRECTORY, name)
+    )
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts
new file mode 100644
index 0000000000000..6ecbcb4e1f0d0
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts
@@ -0,0 +1,9 @@
+import { Manifest, ManifestLoader } from './manifest-loader'
+
+export class ServerManifestLoader implements ManifestLoader {
+  constructor(private readonly getter: (name: string) => Manifest | null) {}
+
+  public load(name: string): Manifest | null {
+    return this.getter(name)
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts
new file mode 100644
index 0000000000000..e9dffe1997359
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts
@@ -0,0 +1,17 @@
+import { RouteMatcher } from '../route-matchers/route-matcher'
+import {
+  Manifest,
+  ManifestLoader,
+} from './helpers/manifest-loaders/manifest-loader'
+import { CachedRouteMatcherProvider } from './helpers/cached-route-matcher-provider'
+
+export abstract class ManifestRouteMatcherProvider<
+  M extends RouteMatcher = RouteMatcher
+> extends CachedRouteMatcherProvider<M, Manifest | null> {
+  constructor(manifestName: string, manifestLoader: ManifestLoader) {
+    super({
+      load: async () => manifestLoader.load(manifestName),
+      compare: (left, right) => left === right,
+    })
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..fa860ed795bd3
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts
@@ -0,0 +1,69 @@
+import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants'
+import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition'
+import { RouteKind } from '../route-kind'
+import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader'
+import { PagesAPIRouteMatcherProvider } from './pages-api-route-matcher-provider'
+
+describe('PagesAPIRouteMatcherProvider', () => {
+  it('returns no routes with an empty manifest', async () => {
+    const loader: ManifestLoader = { load: jest.fn(() => ({})) }
+    const provider = new PagesAPIRouteMatcherProvider('<root>', loader)
+    expect(await provider.matchers()).toEqual([])
+    expect(loader.load).toBeCalledWith(PAGES_MANIFEST)
+  })
+
+  describe('manifest matching', () => {
+    it.each<{
+      manifest: Record<string, string>
+      route: PagesAPIRouteDefinition
+    }>([
+      {
+        manifest: { '/api/users/[id]': 'pages/api/users/[id].js' },
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api/users/[id]',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/api/users/[id].js`,
+          page: '/api/users/[id]',
+          bundlePath: 'pages/api/users/[id]',
+        },
+      },
+      {
+        manifest: { '/api/users': 'pages/api/users.js' },
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api/users',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/api/users.js`,
+          page: '/api/users',
+          bundlePath: 'pages/api/users',
+        },
+      },
+      {
+        manifest: { '/api': 'pages/api.js' },
+        route: {
+          kind: RouteKind.PAGES_API,
+          pathname: '/api',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/api.js`,
+          page: '/api',
+          bundlePath: 'pages/api',
+        },
+      },
+    ])(
+      'returns the correct routes for $route.pathname',
+      async ({ manifest, route }) => {
+        const loader: ManifestLoader = {
+          load: jest.fn(() => ({
+            '/users/[id]': 'pages/users/[id].js',
+            '/users': 'pages/users.js',
+            ...manifest,
+          })),
+        }
+        const provider = new PagesAPIRouteMatcherProvider('<root>', loader)
+        const matchers = await provider.matchers()
+
+        expect(loader.load).toBeCalledWith(PAGES_MANIFEST)
+        expect(matchers).toHaveLength(1)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts
new file mode 100644
index 0000000000000..0f736116d0eaf
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts
@@ -0,0 +1,68 @@
+import path from '../../../shared/lib/isomorphic/path'
+import { isAPIRoute } from '../../../lib/is-api-route'
+import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants'
+import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path'
+import { RouteKind } from '../route-kind'
+import {
+  PagesAPILocaleRouteMatcher,
+  PagesAPIRouteMatcher,
+} from '../route-matchers/pages-api-route-matcher'
+import {
+  Manifest,
+  ManifestLoader,
+} from './helpers/manifest-loaders/manifest-loader'
+import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider'
+import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer'
+
+export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider<PagesAPIRouteMatcher> {
+  constructor(
+    private readonly distDir: string,
+    manifestLoader: ManifestLoader,
+    private readonly localeNormalizer?: LocaleRouteNormalizer
+  ) {
+    super(PAGES_MANIFEST, manifestLoader)
+  }
+
+  protected async transform(
+    manifest: Manifest
+  ): Promise<ReadonlyArray<PagesAPIRouteMatcher>> {
+    // This matcher is only for Pages API routes.
+    const pathnames = Object.keys(manifest).filter((pathname) =>
+      isAPIRoute(pathname)
+    )
+
+    const matchers: Array<PagesAPIRouteMatcher> = []
+
+    for (const page of pathnames) {
+      if (this.localeNormalizer) {
+        // Match the locale on the page name, or default to the default locale.
+        const { detectedLocale, pathname } = this.localeNormalizer.match(page)
+
+        matchers.push(
+          new PagesAPILocaleRouteMatcher({
+            kind: RouteKind.PAGES_API,
+            pathname,
+            page,
+            bundlePath: path.join('pages', normalizePagePath(page)),
+            filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]),
+            i18n: {
+              locale: detectedLocale,
+            },
+          })
+        )
+      } else {
+        matchers.push(
+          new PagesAPIRouteMatcher({
+            kind: RouteKind.PAGES_API,
+            pathname: page,
+            page,
+            bundlePath: path.join('pages', normalizePagePath(page)),
+            filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]),
+          })
+        )
+      }
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts
new file mode 100644
index 0000000000000..fd45776ac730e
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts
@@ -0,0 +1,185 @@
+import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants'
+import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer'
+import { PagesRouteDefinition } from '../route-definitions/pages-route-definition'
+import { RouteKind } from '../route-kind'
+import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader'
+import { PagesRouteMatcherProvider } from './pages-route-matcher-provider'
+
+describe('PagesRouteMatcherProvider', () => {
+  it('returns no routes with an empty manifest', async () => {
+    const loader: ManifestLoader = { load: jest.fn(() => ({})) }
+    const provider = new PagesRouteMatcherProvider('<root>', loader)
+    expect(await provider.matchers()).toEqual([])
+    expect(loader.load).toBeCalledWith(PAGES_MANIFEST)
+  })
+
+  describe('locale matching', () => {
+    describe.each<{
+      manifest: Record<string, string>
+      routes: ReadonlyArray<PagesRouteDefinition>
+      i18n: { locales: ReadonlyArray<string>; defaultLocale: string }
+    }>([
+      {
+        manifest: {
+          '/_app': 'pages/_app.js',
+          '/_error': 'pages/_error.js',
+          '/_document': 'pages/_document.js',
+          '/blog/[slug]': 'pages/blog/[slug].js',
+          '/en-US/404': 'pages/en-US/404.html',
+          '/fr/404': 'pages/fr/404.html',
+          '/nl-NL/404': 'pages/nl-NL/404.html',
+          '/en-US': 'pages/en-US.html',
+          '/fr': 'pages/fr.html',
+          '/nl-NL': 'pages/nl-NL.html',
+        },
+        i18n: { locales: ['en-US', 'fr', 'nl-NL'], defaultLocale: 'en-US' },
+        routes: [
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/blog/[slug]',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/blog/[slug].js`,
+            page: '/blog/[slug]',
+            bundlePath: 'pages/blog/[slug]',
+            i18n: {},
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/en-US.html`,
+            page: '/en-US',
+            bundlePath: 'pages/en-US',
+            i18n: {
+              locale: 'en-US',
+            },
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/fr.html`,
+            page: '/fr',
+            bundlePath: 'pages/fr',
+            i18n: {
+              locale: 'fr',
+            },
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/nl-NL.html`,
+            page: '/nl-NL',
+            bundlePath: 'pages/nl-NL',
+            i18n: {
+              locale: 'nl-NL',
+            },
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/404',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/en-US/404.html`,
+            page: '/en-US/404',
+            bundlePath: 'pages/en-US/404',
+            i18n: {
+              locale: 'en-US',
+            },
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/404',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/fr/404.html`,
+            page: '/fr/404',
+            bundlePath: 'pages/fr/404',
+            i18n: {
+              locale: 'fr',
+            },
+          },
+          {
+            kind: RouteKind.PAGES,
+            pathname: '/404',
+            filename: `<root>/${SERVER_DIRECTORY}/pages/nl-NL/404.html`,
+            page: '/nl-NL/404',
+            bundlePath: 'pages/nl-NL/404',
+            i18n: {
+              locale: 'nl-NL',
+            },
+          },
+        ],
+      },
+    ])('locale', ({ routes: expected, manifest, i18n }) => {
+      it.each(expected)('has the match for $pathname', async (route) => {
+        const loader: ManifestLoader = {
+          load: jest.fn(() => ({
+            '/api/users/[id]': 'pages/api/users/[id].js',
+            '/api/users': 'pages/api/users.js',
+            ...manifest,
+          })),
+        }
+        const provider = new PagesRouteMatcherProvider(
+          '<root>',
+          loader,
+          new LocaleRouteNormalizer(i18n.locales, i18n.defaultLocale)
+        )
+        const matchers = await provider.matchers()
+
+        expect(loader.load).toBeCalledWith(PAGES_MANIFEST)
+        const routes = matchers.map((matcher) => matcher.definition)
+        expect(routes).toContainEqual(route)
+        expect(routes).toHaveLength(expected.length)
+      })
+    })
+  })
+
+  describe('manifest matching', () => {
+    it.each<{
+      manifest: Record<string, string>
+      route: PagesRouteDefinition
+    }>([
+      {
+        manifest: { '/users/[id]': 'pages/users/[id].js' },
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/users/[id]',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/users/[id].js`,
+          page: '/users/[id]',
+          bundlePath: 'pages/users/[id]',
+        },
+      },
+      {
+        manifest: { '/users': 'pages/users.js' },
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/users',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/users.js`,
+          page: '/users',
+          bundlePath: 'pages/users',
+        },
+      },
+      {
+        manifest: { '/': 'pages/index.js' },
+        route: {
+          kind: RouteKind.PAGES,
+          pathname: '/',
+          filename: `<root>/${SERVER_DIRECTORY}/pages/index.js`,
+          page: '/',
+          bundlePath: 'pages/index',
+        },
+      },
+    ])(
+      'returns the correct routes for $route.pathname',
+      async ({ manifest, route }) => {
+        const loader: ManifestLoader = {
+          load: jest.fn(() => ({
+            '/api/users/[id]': 'pages/api/users/[id].js',
+            '/api/users': 'pages/api/users.js',
+            ...manifest,
+          })),
+        }
+        const matcher = new PagesRouteMatcherProvider('<root>', loader)
+        const matchers = await matcher.matchers()
+
+        expect(loader.load).toBeCalledWith(PAGES_MANIFEST)
+        expect(matchers).toHaveLength(1)
+        expect(matchers[0].definition).toEqual(route)
+      }
+    )
+  })
+})
diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts
new file mode 100644
index 0000000000000..061a53ba41d66
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts
@@ -0,0 +1,82 @@
+import path from '../../../shared/lib/isomorphic/path'
+import { isAPIRoute } from '../../../lib/is-api-route'
+import {
+  BLOCKED_PAGES,
+  PAGES_MANIFEST,
+  SERVER_DIRECTORY,
+} from '../../../shared/lib/constants'
+import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path'
+import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer'
+import { RouteKind } from '../route-kind'
+import {
+  PagesLocaleRouteMatcher,
+  PagesRouteMatcher,
+} from '../route-matchers/pages-route-matcher'
+import {
+  Manifest,
+  ManifestLoader,
+} from './helpers/manifest-loaders/manifest-loader'
+import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider'
+
+export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider<PagesRouteMatcher> {
+  constructor(
+    private readonly distDir: string,
+    manifestLoader: ManifestLoader,
+    private readonly localeNormalizer?: LocaleRouteNormalizer
+  ) {
+    super(PAGES_MANIFEST, manifestLoader)
+  }
+
+  protected async transform(
+    manifest: Manifest
+  ): Promise<ReadonlyArray<PagesRouteMatcher>> {
+    // This matcher is only for Pages routes, not Pages API routes which are
+    // included in this manifest.
+    const pathnames = Object.keys(manifest)
+      .filter((pathname) => !isAPIRoute(pathname))
+      // Remove any blocked pages (page that can't be routed to, like error or
+      // internal pages).
+      .filter((pathname) => {
+        const normalized =
+          this.localeNormalizer?.normalize(pathname) ?? pathname
+
+        // Skip any blocked pages.
+        if (BLOCKED_PAGES.includes(normalized)) return false
+
+        return true
+      })
+
+    const matchers: Array<PagesRouteMatcher> = []
+    for (const page of pathnames) {
+      if (this.localeNormalizer) {
+        // Match the locale on the page name, or default to the default locale.
+        const { detectedLocale, pathname } = this.localeNormalizer.match(page)
+
+        matchers.push(
+          new PagesLocaleRouteMatcher({
+            kind: RouteKind.PAGES,
+            pathname,
+            page,
+            bundlePath: path.join('pages', normalizePagePath(page)),
+            filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]),
+            i18n: {
+              locale: detectedLocale,
+            },
+          })
+        )
+      } else {
+        matchers.push(
+          new PagesRouteMatcher({
+            kind: RouteKind.PAGES,
+            pathname: page,
+            page,
+            bundlePath: path.join('pages', normalizePagePath(page)),
+            filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]),
+          })
+        )
+      }
+    }
+
+    return matchers
+  }
+}
diff --git a/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts
new file mode 100644
index 0000000000000..6263a9db5cf1b
--- /dev/null
+++ b/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts
@@ -0,0 +1,5 @@
+import { RouteMatcher } from '../route-matchers/route-matcher'
+
+export interface RouteMatcherProvider<M extends RouteMatcher = RouteMatcher> {
+  matchers(): Promise<ReadonlyArray<M>>
+}
diff --git a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts
new file mode 100644
index 0000000000000..54727515c681b
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts
@@ -0,0 +1,10 @@
+import { RouteMatcher } from './route-matcher'
+import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition'
+
+export class AppPageRouteMatcher extends RouteMatcher<AppPageRouteDefinition> {
+  public get identity(): string {
+    return `${
+      this.definition.pathname
+    }?__nextParallelPaths=${this.definition.appPaths.join(',')}}`
+  }
+}
diff --git a/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts
new file mode 100644
index 0000000000000..0bc3720ff9d2d
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts
@@ -0,0 +1,4 @@
+import { RouteMatcher } from './route-matcher'
+import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition'
+
+export class AppRouteRouteMatcher extends RouteMatcher<AppRouteRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matchers/locale-route-matcher.ts b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts
new file mode 100644
index 0000000000000..dc72787a0f79e
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts
@@ -0,0 +1,74 @@
+import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition'
+import { LocaleRouteMatch } from '../route-matches/locale-route-match'
+import { RouteMatcher } from './route-matcher'
+
+export type LocaleMatcherMatchOptions = {
+  /**
+   * If defined, this indicates to the matcher that the request should be
+   * treated as locale-aware. If this is undefined, it means that this
+   * application was not configured for additional locales.
+   */
+  i18n?: {
+    /**
+     * The locale that was detected on the incoming route. If undefined it means
+     * that the locale should be considered to be the default one.
+     */
+    detectedLocale?: string
+
+    /**
+     * The pathname that has had it's locale information stripped from.
+     */
+    pathname: string
+  }
+}
+
+export class LocaleRouteMatcher<
+  D extends LocaleRouteDefinition = LocaleRouteDefinition
+> extends RouteMatcher<D> {
+  /**
+   * Identity returns the identity part of the matcher. This is used to compare
+   * a unique matcher to another. This is also used when sorting dynamic routes,
+   * so it must contain the pathname part as well.
+   */
+  public get identity(): string {
+    return `${this.definition.pathname}?__nextLocale=${this.definition.i18n?.locale}`
+  }
+
+  public match(
+    pathname: string,
+    options?: LocaleMatcherMatchOptions
+  ): LocaleRouteMatch<D> | null {
+    // This is like the parent `match` method but instead this injects the
+    // additional `options` into the
+    const result = this.test(pathname, options)
+    if (!result) return null
+
+    return {
+      definition: this.definition,
+      params: result.params,
+      detectedLocale:
+        options?.i18n?.detectedLocale ?? this.definition.i18n?.locale,
+    }
+  }
+
+  public test(pathname: string, options?: LocaleMatcherMatchOptions) {
+    // If this route has locale information...
+    if (this.definition.i18n && options?.i18n) {
+      // If we have detected a locale and it does not match this route's locale,
+      // then this isn't a match!
+      if (
+        this.definition.i18n.locale &&
+        options.i18n.detectedLocale &&
+        this.definition.i18n.locale !== options.i18n.detectedLocale
+      ) {
+        return null
+      }
+
+      // Perform regular matching against the locale stripped pathname now, the
+      // locale information matches!
+      return super.test(options.i18n.pathname)
+    }
+
+    return super.test(pathname)
+  }
+}
diff --git a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts
new file mode 100644
index 0000000000000..84cd8eec412af
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts
@@ -0,0 +1,7 @@
+import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition'
+import { LocaleRouteMatcher } from './locale-route-matcher'
+import { RouteMatcher } from './route-matcher'
+
+export class PagesAPIRouteMatcher extends RouteMatcher<PagesAPIRouteDefinition> {}
+
+export class PagesAPILocaleRouteMatcher extends LocaleRouteMatcher<PagesAPIRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matchers/pages-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts
new file mode 100644
index 0000000000000..aacf69d419ccb
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts
@@ -0,0 +1,7 @@
+import { PagesRouteDefinition } from '../route-definitions/pages-route-definition'
+import { LocaleRouteMatcher } from './locale-route-matcher'
+import { RouteMatcher } from './route-matcher'
+
+export class PagesRouteMatcher extends RouteMatcher<PagesRouteDefinition> {}
+
+export class PagesLocaleRouteMatcher extends LocaleRouteMatcher<PagesRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-matchers/route-matcher.ts
new file mode 100644
index 0000000000000..8be68356d5277
--- /dev/null
+++ b/packages/next/src/server/future/route-matchers/route-matcher.ts
@@ -0,0 +1,61 @@
+import { isDynamicRoute } from '../../../shared/lib/router/utils'
+import {
+  getRouteMatcher,
+  Params,
+  RouteMatchFn,
+} from '../../../shared/lib/router/utils/route-matcher'
+import { getRouteRegex } from '../../../shared/lib/router/utils/route-regex'
+import { RouteDefinition } from '../route-definitions/route-definition'
+import { RouteMatch } from '../route-matches/route-match'
+
+export class RouteMatcher<D extends RouteDefinition = RouteDefinition> {
+  private readonly dynamic?: RouteMatchFn
+
+  /**
+   * When set, this is an array of all the other matchers that are duplicates of
+   * this one. This is used by the managers to warn the users about possible
+   * duplicate matches on routes.
+   */
+  public duplicated?: Array<RouteMatcher>
+
+  constructor(public readonly definition: D) {
+    if (isDynamicRoute(definition.pathname)) {
+      this.dynamic = getRouteMatcher(getRouteRegex(definition.pathname))
+    }
+  }
+
+  /**
+   * Identity returns the identity part of the matcher. This is used to compare
+   * a unique matcher to another. This is also used when sorting dynamic routes,
+   * so it must contain the pathname part.
+   */
+  public get identity(): string {
+    return this.definition.pathname
+  }
+
+  public get isDynamic() {
+    return this.dynamic !== undefined
+  }
+
+  public match(pathname: string): RouteMatch<D> | null {
+    const result = this.test(pathname)
+    if (!result) return null
+
+    return { definition: this.definition, params: result.params }
+  }
+
+  public test(pathname: string): { params?: Params } | null {
+    if (this.dynamic) {
+      const params = this.dynamic(pathname)
+      if (!params) return null
+
+      return { params }
+    }
+
+    if (pathname === this.definition.pathname) {
+      return {}
+    }
+
+    return null
+  }
+}
diff --git a/packages/next/src/server/future/route-matches/app-page-route-match.ts b/packages/next/src/server/future/route-matches/app-page-route-match.ts
new file mode 100644
index 0000000000000..dbf1a935c9be6
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/app-page-route-match.ts
@@ -0,0 +1,4 @@
+import { RouteMatch } from './route-match'
+import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition'
+
+export interface AppPageRouteMatch extends RouteMatch<AppPageRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matches/app-route-route-match.ts b/packages/next/src/server/future/route-matches/app-route-route-match.ts
new file mode 100644
index 0000000000000..5804826e5ade3
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/app-route-route-match.ts
@@ -0,0 +1,5 @@
+import { RouteMatch } from './route-match'
+import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition'
+
+export interface AppRouteRouteMatch
+  extends RouteMatch<AppRouteRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matches/locale-route-match.ts b/packages/next/src/server/future/route-matches/locale-route-match.ts
new file mode 100644
index 0000000000000..99004a614812b
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/locale-route-match.ts
@@ -0,0 +1,7 @@
+import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition'
+import { RouteMatch } from './route-match'
+
+export interface LocaleRouteMatch<R extends LocaleRouteDefinition>
+  extends RouteMatch<R> {
+  readonly detectedLocale?: string
+}
diff --git a/packages/next/src/server/future/route-matches/pages-api-route-match.ts b/packages/next/src/server/future/route-matches/pages-api-route-match.ts
new file mode 100644
index 0000000000000..ee842933de818
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/pages-api-route-match.ts
@@ -0,0 +1,5 @@
+import { RouteMatch } from './route-match'
+import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition'
+
+export interface PagesAPIRouteMatch
+  extends RouteMatch<PagesAPIRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matches/pages-route-match.ts b/packages/next/src/server/future/route-matches/pages-route-match.ts
new file mode 100644
index 0000000000000..73551caed1317
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/pages-route-match.ts
@@ -0,0 +1,5 @@
+import { PagesRouteDefinition } from '../route-definitions/pages-route-definition'
+import { LocaleRouteMatch } from './locale-route-match'
+
+export interface PagesRouteMatch
+  extends LocaleRouteMatch<PagesRouteDefinition> {}
diff --git a/packages/next/src/server/future/route-matches/route-match.ts b/packages/next/src/server/future/route-matches/route-match.ts
new file mode 100644
index 0000000000000..758bb58545ec4
--- /dev/null
+++ b/packages/next/src/server/future/route-matches/route-match.ts
@@ -0,0 +1,17 @@
+import { Params } from '../../../shared/lib/router/utils/route-matcher'
+import { RouteDefinition } from '../route-definitions/route-definition'
+
+/**
+ * RouteMatch is the resolved match for a given request. This will contain all
+ * the dynamic parameters used for this route.
+ */
+export interface RouteMatch<D extends RouteDefinition = RouteDefinition> {
+  readonly definition: D
+
+  /**
+   * params when provided are the dynamic route parameters that were parsed from
+   * the incoming request pathname. If a route match is returned without any
+   * params, it should be considered a static route.
+   */
+  readonly params?: Params
+}
diff --git a/packages/next/src/server/load-components.ts b/packages/next/src/server/load-components.ts
index eb68fd09cc0df..11039706ed4c7 100644
--- a/packages/next/src/server/load-components.ts
+++ b/packages/next/src/server/load-components.ts
@@ -12,8 +12,8 @@ import type {
 import {
   BUILD_MANIFEST,
   REACT_LOADABLE_MANIFEST,
-  FLIGHT_MANIFEST,
-  ACTIONS_MANIFEST,
+  CLIENT_REFERENCE_MANIFEST,
+  SERVER_REFERENCE_MANIFEST,
 } from '../shared/lib/constants'
 import { join } from 'path'
 import { requirePage } from './require'
@@ -108,12 +108,14 @@ export async function loadComponents({
     loadManifest<BuildManifest>(join(distDir, BUILD_MANIFEST)),
     loadManifest<ReactLoadableManifest>(join(distDir, REACT_LOADABLE_MANIFEST)),
     hasServerComponents
-      ? loadManifest(join(distDir, 'server', FLIGHT_MANIFEST + '.json'))
+      ? loadManifest(
+          join(distDir, 'server', CLIENT_REFERENCE_MANIFEST + '.json')
+        )
       : null,
     hasServerComponents
-      ? loadManifest(join(distDir, 'server', ACTIONS_MANIFEST + '.json')).catch(
-          () => null
-        )
+      ? loadManifest(
+          join(distDir, 'server', SERVER_REFERENCE_MANIFEST + '.json')
+        ).catch(() => null)
       : null,
   ])
 
diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts
index 62d64924c7478..92555feb8d3a0 100644
--- a/packages/next/src/server/next-server.ts
+++ b/packages/next/src/server/next-server.ts
@@ -3,7 +3,7 @@ import './node-polyfill-fetch'
 import './node-polyfill-web-streams'
 
 import type { TLSSocket } from 'tls'
-import type { Route } from './router'
+import type { Route, RouterOptions } from './router'
 import {
   CacheFs,
   DecodeError,
@@ -21,18 +21,14 @@ import type { PayloadOptions } from './send-payload'
 import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta'
 import type {
   Params,
-  RouteMatch,
+  RouteMatchFn,
 } from '../shared/lib/router/utils/route-matcher'
 import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher'
-import type { NextConfig } from './config-shared'
-import type { DynamicRoutes, PageChecker } from './router'
 
 import fs from 'fs'
 import { join, relative, resolve, sep } from 'path'
 import { IncomingMessage, ServerResponse } from 'http'
 import { addRequestMeta, getRequestMeta } from './request-meta'
-import { isAPIRoute } from '../lib/is-api-route'
-import { isDynamicRoute } from '../shared/lib/router/utils'
 import {
   PAGES_MANIFEST,
   BUILD_ID_FILE,
@@ -41,7 +37,7 @@ import {
   CLIENT_STATIC_FILES_RUNTIME,
   PRERENDER_MANIFEST,
   ROUTES_MANIFEST,
-  FLIGHT_MANIFEST,
+  CLIENT_REFERENCE_MANIFEST,
   CLIENT_PUBLIC_FILES_PATH,
   APP_PATHS_MANIFEST,
   FLIGHT_SERVER_CSS_MANIFEST,
@@ -92,7 +88,7 @@ import { getCustomRoute, stringifyQuery } from './server-route-utils'
 import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring'
 import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
 import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info'
-import { getClonableBody } from './body-streams'
+import { getCloneableBody } from './body-streams'
 import { checkIsManualRevalidate } from './api-utils'
 import ResponseCache from './response-cache'
 import { IncrementalCache } from './lib/incremental-cache'
@@ -100,6 +96,11 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
 
 import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render'
 import { setHttpClientAndAgentOptions } from './config'
+import { RouteKind } from './future/route-kind'
+
+import { AppRouteRouteHandler } from './future/route-handlers/app-route-route-handler'
+import { PagesAPIRouteMatch } from './future/route-matches/pages-api-route-match'
+import { MatchOptions } from './future/route-matcher-managers/route-matcher-manager'
 
 export * from './base-server'
 
@@ -124,7 +125,7 @@ const MiddlewareMatcherCache = new WeakMap<
 
 const EdgeMatcherCache = new WeakMap<
   MiddlewareManifest['functions'][string],
-  RouteMatch
+  RouteMatchFn
 >()
 
 function getMiddlewareMatcher(
@@ -183,7 +184,7 @@ const POSSIBLE_ERROR_CODE_FROM_SERVE_STATIC = new Set([
 
 function getEdgeMatcher(
   info: MiddlewareManifest['functions'][string]
-): RouteMatch {
+): RouteMatchFn {
   const stored = EdgeMatcherCache.get(info)
   if (stored) {
     return stored
@@ -261,6 +262,16 @@ export default class NextNodeServer extends BaseServer {
     setHttpClientAndAgentOptions(this.nextConfig)
   }
 
+  protected getRoutes() {
+    const routes = super.getRoutes()
+
+    if (this.hasAppDir) {
+      routes.handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler())
+    }
+
+    return routes
+  }
+
   protected loadEnvConfig({
     dev,
     forceReload,
@@ -326,10 +337,10 @@ export default class NextNodeServer extends BaseServer {
   }
 
   protected getAppPathsManifest(): PagesManifest | undefined {
-    if (this.hasAppDir) {
-      const appPathsManifestPath = join(this.serverDistDir, APP_PATHS_MANIFEST)
-      return require(appPathsManifestPath)
-    }
+    if (!this.hasAppDir) return undefined
+
+    const appPathsManifestPath = join(this.serverDistDir, APP_PATHS_MANIFEST)
+    return require(appPathsManifestPath)
   }
 
   protected async hasPage(pathname: string): Promise<boolean> {
@@ -1004,7 +1015,11 @@ export default class NextNodeServer extends BaseServer {
 
   protected getServerComponentManifest() {
     if (!this.hasAppDir) return undefined
-    return require(join(this.distDir, 'server', FLIGHT_MANIFEST + '.json'))
+    return require(join(
+      this.distDir,
+      'server',
+      CLIENT_REFERENCE_MANIFEST + '.json'
+    ))
   }
 
   protected getServerCSSManifest() {
@@ -1027,22 +1042,7 @@ export default class NextNodeServer extends BaseServer {
     return cacheFs.readFile(join(this.serverDistDir, 'pages', `${page}.html`))
   }
 
-  protected generateRoutes(): {
-    headers: Route[]
-    rewrites: {
-      beforeFiles: Route[]
-      afterFiles: Route[]
-      fallback: Route[]
-    }
-    fsRoutes: Route[]
-    redirects: Route[]
-    catchAllRoute: Route
-    catchAllMiddleware: Route[]
-    pageChecker: PageChecker
-    useFileSystemPublicRoutes: boolean
-    dynamicRoutes: DynamicRoutes | undefined
-    nextConfig: NextConfig
-  } {
+  protected generateRoutes(): RouterOptions {
     const publicRoutes = this.generatePublicRoutes()
     const imageRoutes = this.generateImageRoutes()
     const staticFilesRoutes = this.generateStaticRoutes()
@@ -1190,25 +1190,36 @@ export default class NextNodeServer extends BaseServer {
         // next.js core assumes page path without trailing slash
         pathname = removeTrailingSlash(pathname)
 
-        if (this.nextConfig.i18n) {
-          const localePathResult = normalizeLocalePath(
-            pathname,
-            this.nextConfig.i18n?.locales
-          )
-
-          if (localePathResult.detectedLocale) {
-            pathname = localePathResult.pathname
-            parsedUrl.query.__nextLocale = localePathResult.detectedLocale
-          }
+        const options: MatchOptions = {
+          i18n: this.localeNormalizer?.match(pathname),
+        }
+        if (options.i18n?.detectedLocale) {
+          parsedUrl.query.__nextLocale = options.i18n.detectedLocale
         }
+
         const bubbleNoFallback = !!query._nextBubbleNoFallback
 
-        if (isAPIRoute(pathname)) {
-          delete query._nextBubbleNoFallback
+        const match = await this.matchers.match(pathname, options)
 
-          const handled = await this.handleApiRequest(req, res, pathname, query)
-          if (handled) {
-            return { finished: true }
+        // Try to handle the given route with the configured handlers.
+        if (match) {
+          let handled = await this.handlers.handle(match, req, res)
+          if (handled) return { finished: true }
+
+          // If the route was detected as being a Pages API route, then handle
+          // it.
+          // TODO: move this behavior into a route handler.
+          if (match.definition.kind === RouteKind.PAGES_API) {
+            delete query._nextBubbleNoFallback
+
+            handled = await this.handleApiRequest(
+              req,
+              res,
+              query,
+              // TODO: see if we can add a runtime check for this
+              match as PagesAPIRouteMatch
+            )
+            if (handled) return { finished: true }
           }
         }
 
@@ -1233,7 +1244,6 @@ export default class NextNodeServer extends BaseServer {
 
     if (useFileSystemPublicRoutes) {
       this.appPathRoutes = this.getAppPathRoutes()
-      this.dynamicRoutes = this.getDynamicRoutes()
     }
 
     return {
@@ -1244,15 +1254,12 @@ export default class NextNodeServer extends BaseServer {
       catchAllRoute,
       catchAllMiddleware,
       useFileSystemPublicRoutes,
-      dynamicRoutes: this.dynamicRoutes,
-      pageChecker: this.hasPage.bind(this),
+      matchers: this.matchers,
       nextConfig: this.nextConfig,
+      localeNormalizer: this.localeNormalizer,
     }
   }
 
-  // Used to build API page in development
-  protected async ensureApiPage(_pathname: string): Promise<void> {}
-
   /**
    * Resolves `API` request, in development builds on demand
    * @param req http request
@@ -1262,42 +1269,15 @@ export default class NextNodeServer extends BaseServer {
   protected async handleApiRequest(
     req: BaseNextRequest,
     res: BaseNextResponse,
-    pathname: string,
-    query: ParsedUrlQuery
+    query: ParsedUrlQuery,
+    match: PagesAPIRouteMatch
   ): Promise<boolean> {
-    let page = pathname
-    let params: Params | undefined = undefined
-    let pageFound = !isDynamicRoute(page) && (await this.hasPage(page))
-
-    if (!pageFound && this.dynamicRoutes) {
-      for (const dynamicRoute of this.dynamicRoutes) {
-        params = dynamicRoute.match(pathname) || undefined
-        if (isAPIRoute(dynamicRoute.page) && params) {
-          page = dynamicRoute.page
-          pageFound = true
-          break
-        }
-      }
-    }
-
-    if (!pageFound) {
-      return false
-    }
-    // Make sure the page is built before getting the path
-    // or else it won't be in the manifest yet
-    await this.ensureApiPage(page)
-
-    let builtPagePath
-    try {
-      builtPagePath = this.getPagePath(page)
-    } catch (err) {
-      if (isError(err) && err.code === 'ENOENT') {
-        return false
-      }
-      throw err
-    }
+    const {
+      definition: { pathname, filename },
+      params,
+    } = match
 
-    return this.runApi(req, res, query, params, page, builtPagePath)
+    return this.runApi(req, res, query, params, pathname, filename)
   }
 
   protected getCacheFilesystem(): CacheFs {
@@ -1415,7 +1395,7 @@ export default class NextNodeServer extends BaseServer {
     path: string,
     parsedUrl?: UrlWithParsedQuery
   ): Promise<void> {
-    if (!this.isServeableUrl(path)) {
+    if (!this.isServableUrl(path)) {
       return this.render404(req, res, parsedUrl)
     }
 
@@ -1470,7 +1450,7 @@ export default class NextNodeServer extends BaseServer {
       : []
   }
 
-  protected isServeableUrl(untrustedFileUrl: string): boolean {
+  protected isServableUrl(untrustedFileUrl: string): boolean {
     // This method mimics what the version of `send` we use does:
     // 1. decodeURIComponent:
     //    https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
@@ -1719,6 +1699,9 @@ export default class NextNodeServer extends BaseServer {
 
     let url: string
 
+    const options: MatchOptions = {
+      i18n: this.localeNormalizer?.match(normalizedPathname),
+    }
     if (this.nextConfig.skipMiddlewareUrlNormalize) {
       url = getRequestMeta(params.request, '__NEXT_INIT_URL')!
     } else {
@@ -1740,17 +1723,13 @@ export default class NextNodeServer extends BaseServer {
     }
 
     const page: { name?: string; params?: { [key: string]: string } } = {}
-    if (await this.hasPage(normalizedPathname)) {
-      page.name = params.parsedUrl.pathname
-    } else if (this.dynamicRoutes) {
-      for (const dynamicRoute of this.dynamicRoutes) {
-        const matchParams = dynamicRoute.match(normalizedPathname)
-        if (matchParams) {
-          page.name = dynamicRoute.page
-          page.params = matchParams
-          break
-        }
-      }
+
+    const match = await this.matchers.match(normalizedPathname, options)
+    if (match) {
+      page.name = match.params
+        ? match.definition.pathname
+        : params.parsedUrl.pathname
+      page.params = match.params
     }
 
     const middleware = this.getMiddleware()
@@ -2077,7 +2056,7 @@ export default class NextNodeServer extends BaseServer {
     addRequestMeta(req, '__NEXT_INIT_URL', initUrl)
     addRequestMeta(req, '__NEXT_INIT_QUERY', { ...parsedUrl.query })
     addRequestMeta(req, '_protocol', protocol)
-    addRequestMeta(req, '__NEXT_CLONABLE_BODY', getClonableBody(req.body))
+    addRequestMeta(req, '__NEXT_CLONABLE_BODY', getCloneableBody(req.body))
   }
 
   protected async runEdgeFunction(params: {
diff --git a/packages/next/src/server/node-polyfill-headers.ts b/packages/next/src/server/node-polyfill-headers.ts
new file mode 100644
index 0000000000000..eabd1007b96db
--- /dev/null
+++ b/packages/next/src/server/node-polyfill-headers.ts
@@ -0,0 +1,16 @@
+/**
+ * Polyfills the `Headers.getAll(name)` method so it'll work in the edge
+ * runtime.
+ */
+
+if (!('getAll' in Headers.prototype)) {
+  // @ts-expect-error - this is polyfilling this method so it doesn't exist yet
+  Headers.prototype.getAll = function (name: string) {
+    name = name.toLowerCase()
+    if (name !== 'set-cookie')
+      throw new Error('Headers.getAll is only supported for Set-Cookie header')
+
+    const headers = [...this.entries()].filter(([key]) => key === name)
+    return headers.map(([, value]) => value)
+  }
+}
diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts
index aa40d1b84952b..40085d94d0ea8 100644
--- a/packages/next/src/server/request-meta.ts
+++ b/packages/next/src/server/request-meta.ts
@@ -3,7 +3,7 @@ import type { IncomingMessage } from 'http'
 import type { ParsedUrlQuery } from 'querystring'
 import type { UrlWithParsedQuery } from 'url'
 import type { BaseNextRequest } from './base-http'
-import type { ClonableBody } from './body-streams'
+import type { CloneableBody } from './body-streams'
 
 export const NEXT_REQUEST_META = Symbol('NextRequestMeta')
 
@@ -14,7 +14,7 @@ export type NextIncomingMessage = (BaseNextRequest | IncomingMessage) & {
 export interface RequestMeta {
   __NEXT_INIT_QUERY?: ParsedUrlQuery
   __NEXT_INIT_URL?: string
-  __NEXT_CLONABLE_BODY?: ClonableBody
+  __NEXT_CLONABLE_BODY?: CloneableBody
   __nextHadTrailingSlash?: boolean
   __nextIsLocaleDomain?: boolean
   __nextStrippedLocale?: boolean
diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts
index 91ddd1090c93f..01b8466565448 100644
--- a/packages/next/src/server/router.ts
+++ b/packages/next/src/server/router.ts
@@ -2,7 +2,7 @@ import type { NextConfig } from './config'
 import type { ParsedUrlQuery } from 'querystring'
 import type { BaseNextRequest, BaseNextResponse } from './base-http'
 import type {
-  RouteMatch,
+  RouteMatchFn,
   Params,
 } from '../shared/lib/router/utils/route-matcher'
 import type { RouteHas } from '../lib/load-custom-routes'
@@ -14,13 +14,17 @@ import {
 } from './request-meta'
 import { isAPIRoute } from '../lib/is-api-route'
 import { getPathMatch } from '../shared/lib/router/utils/path-match'
-import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
-import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
 import { matchHas } from '../shared/lib/router/utils/prepare-destination'
 import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix'
 import { getRequestMeta } from './request-meta'
 import { formatNextPathnameInfo } from '../shared/lib/router/utils/format-next-pathname-info'
 import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info'
+import {
+  MatchOptions,
+  RouteMatcherManager,
+} from './future/route-matcher-managers/route-matcher-manager'
+import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
+import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer'
 
 type RouteResult = {
   finished: boolean
@@ -29,7 +33,7 @@ type RouteResult = {
 }
 
 export type Route = {
-  match: RouteMatch
+  match: RouteMatchFn
   has?: RouteHas[]
   missing?: RouteHas[]
   type: string
@@ -50,7 +54,22 @@ export type Route = {
   ) => Promise<RouteResult> | RouteResult
 }
 
-export type DynamicRoutes = Array<{ page: string; match: RouteMatch }>
+export type RouterOptions = {
+  headers: ReadonlyArray<Route>
+  fsRoutes: ReadonlyArray<Route>
+  rewrites: {
+    beforeFiles: ReadonlyArray<Route>
+    afterFiles: ReadonlyArray<Route>
+    fallback: ReadonlyArray<Route>
+  }
+  redirects: ReadonlyArray<Route>
+  catchAllRoute: Route
+  catchAllMiddleware: ReadonlyArray<Route>
+  matchers: RouteMatcherManager
+  useFileSystemPublicRoutes: boolean
+  nextConfig: NextConfig
+  localeNormalizer?: LocaleRouteNormalizer
+}
 
 export type PageChecker = (pathname: string) => Promise<boolean>
 
@@ -66,27 +85,13 @@ export default class Router {
     fallback: ReadonlyArray<Route>
   }
   private readonly catchAllRoute: Route
-  private readonly pageChecker: PageChecker
-  private dynamicRoutes: DynamicRoutes
+  private readonly matchers: Pick<RouteMatcherManager, 'test'>
   private readonly useFileSystemPublicRoutes: boolean
   private readonly nextConfig: NextConfig
+  private readonly localeNormalizer?: LocaleRouteNormalizer
   private compiledRoutes: ReadonlyArray<Route>
   private needsRecompilation: boolean
 
-  /**
-   * context stores information used by the router.
-   */
-  private readonly context = new WeakMap<
-    BaseNextRequest,
-    {
-      /**
-       * pageChecks is the memoized record of all checks made against pages to
-       * help de-duplicate work.
-       */
-      pageChecks: Record<string, boolean>
-    }
-  >()
-
   constructor({
     headers = [],
     fsRoutes = [],
@@ -98,76 +103,31 @@ export default class Router {
     redirects = [],
     catchAllRoute,
     catchAllMiddleware = [],
-    dynamicRoutes = [],
-    pageChecker,
+    matchers,
     useFileSystemPublicRoutes,
     nextConfig,
-  }: {
-    headers: ReadonlyArray<Route>
-    fsRoutes: ReadonlyArray<Route>
-    rewrites: {
-      beforeFiles: ReadonlyArray<Route>
-      afterFiles: ReadonlyArray<Route>
-      fallback: ReadonlyArray<Route>
-    }
-    redirects: ReadonlyArray<Route>
-    catchAllRoute: Route
-    catchAllMiddleware: ReadonlyArray<Route>
-    dynamicRoutes: DynamicRoutes | undefined
-    pageChecker: PageChecker
-    useFileSystemPublicRoutes: boolean
-    nextConfig: NextConfig
-  }) {
+    localeNormalizer,
+  }: RouterOptions) {
     this.nextConfig = nextConfig
     this.headers = headers
     this.fsRoutes = [...fsRoutes]
     this.rewrites = rewrites
     this.redirects = redirects
-    this.pageChecker = pageChecker
     this.catchAllRoute = catchAllRoute
     this.catchAllMiddleware = catchAllMiddleware
-    this.dynamicRoutes = dynamicRoutes
+    this.matchers = matchers
     this.useFileSystemPublicRoutes = useFileSystemPublicRoutes
+    this.localeNormalizer = localeNormalizer
 
     // Perform the initial route compilation.
     this.compiledRoutes = this.compileRoutes()
     this.needsRecompilation = false
   }
 
-  private async checkPage(
-    req: BaseNextRequest,
-    pathname: string
-  ): Promise<boolean> {
-    pathname = normalizeLocalePath(pathname, this.locales).pathname
-
-    const context = this.context.get(req)
-    if (!context) {
-      throw new Error(
-        'Invariant: request is not available inside the context, this is an internal error please open an issue.'
-      )
-    }
-
-    if (context.pageChecks[pathname] !== undefined) {
-      return context.pageChecks[pathname]
-    }
-
-    const result = await this.pageChecker(pathname)
-    context.pageChecks[pathname] = result
-    return result
-  }
-
-  get locales() {
-    return this.nextConfig.i18n?.locales || []
-  }
-
   get basePath() {
     return this.nextConfig.basePath || ''
   }
 
-  public setDynamicRoutes(dynamicRoutes: DynamicRoutes) {
-    this.dynamicRoutes = dynamicRoutes
-    this.needsRecompilation = true
-  }
   public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray<Route>) {
     this.catchAllMiddleware = catchAllMiddleware
     this.needsRecompilation = true
@@ -217,22 +177,31 @@ export default class Router {
               name: 'page checker',
               match: getPathMatch('/:path*'),
               fn: async (req, res, params, parsedUrl, upgradeHead) => {
+                // Next.js performs all route matching without the trailing slash.
                 const pathname = removeTrailingSlash(parsedUrl.pathname || '/')
-                if (!pathname) {
-                  return { finished: false }
-                }
 
-                if (await this.checkPage(req, pathname)) {
-                  return this.catchAllRoute.fn(
-                    req,
-                    res,
-                    params,
-                    parsedUrl,
-                    upgradeHead
-                  )
+                // Normalize and detect the locale on the pathname.
+                const options: MatchOptions = {
+                  // We need to skip dynamic route matching because the next
+                  // step we're processing the afterFiles rewrites which must
+                  // not include dynamic matches.
+                  skipDynamic: true,
+                  i18n: this.localeNormalizer?.match(pathname, {
+                    // TODO: verify changing the default locale
+                    inferDefaultLocale: true,
+                  }),
                 }
 
-                return { finished: false }
+                const match = await this.matchers.test(pathname, options)
+                if (!match) return { finished: false }
+
+                return this.catchAllRoute.fn(
+                  req,
+                  res,
+                  params,
+                  parsedUrl,
+                  upgradeHead
+                )
               },
             } as Route,
           ]
@@ -271,64 +240,56 @@ export default class Router {
     parsedUrl: NextUrlWithParsedQuery,
     upgradeHead?: Buffer
   ) {
-    const originalFsPathname = parsedUrl.pathname
-    const fsPathname = removePathPrefix(originalFsPathname!, this.basePath)
+    const fsPathname = removePathPrefix(parsedUrl.pathname!, this.basePath)
 
     for (const route of this.fsRoutes) {
       const params = route.match(fsPathname)
+      if (!params) continue
 
-      if (params) {
-        parsedUrl.pathname = fsPathname
-
-        const { finished } = await route.fn(req, res, params, parsedUrl)
-        if (finished) {
-          return true
-        }
-
-        parsedUrl.pathname = originalFsPathname
+      const { finished } = await route.fn(req, res, params, {
+        ...parsedUrl,
+        pathname: fsPathname,
+      })
+      if (finished) {
+        return true
       }
     }
 
-    let matchedPage = await this.checkPage(req, fsPathname)
-
-    // If we didn't match a page check dynamic routes
-    if (!matchedPage) {
-      const normalizedFsPathname = normalizeLocalePath(
-        fsPathname,
-        this.locales
-      ).pathname
-
-      for (const dynamicRoute of this.dynamicRoutes) {
-        if (dynamicRoute.match(normalizedFsPathname)) {
-          matchedPage = true
-        }
-      }
+    // Normalize and detect the locale on the pathname.
+    const options: MatchOptions = {
+      i18n: this.localeNormalizer?.match(fsPathname, {
+        // TODO: verify changing the default locale
+        inferDefaultLocale: true,
+      }),
     }
 
-    // Matched a page or dynamic route so render it using catchAllRoute
-    if (matchedPage) {
-      const params = this.catchAllRoute.match(parsedUrl.pathname)
-      if (!params) {
-        throw new Error(
-          `Invariant: could not match params, this is an internal error please open an issue.`
-        )
-      }
-
-      parsedUrl.pathname = fsPathname
-      parsedUrl.query._nextBubbleNoFallback = '1'
+    const match = await this.matchers.test(fsPathname, options)
+    if (!match) return false
 
-      const { finished } = await this.catchAllRoute.fn(
-        req,
-        res,
-        params,
-        parsedUrl,
-        upgradeHead
+    // Matched a page or dynamic route so render it using catchAllRoute
+    const params = this.catchAllRoute.match(parsedUrl.pathname)
+    if (!params) {
+      throw new Error(
+        `Invariant: could not match params, this is an internal error please open an issue.`
       )
-
-      return finished
     }
 
-    return false
+    const { finished } = await this.catchAllRoute.fn(
+      req,
+      res,
+      params,
+      {
+        ...parsedUrl,
+        pathname: fsPathname,
+        query: {
+          ...parsedUrl.query,
+          _nextBubbleNoFallback: '1',
+        },
+      },
+      upgradeHead
+    )
+
+    return finished
   }
 
   async execute(
@@ -344,161 +305,150 @@ export default class Router {
       this.needsRecompilation = false
     }
 
-    if (this.context.has(req)) {
-      throw new Error(
-        `Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.`
-      )
+    // Create a deep copy of the parsed URL.
+    const parsedUrlUpdated = {
+      ...parsedUrl,
+      query: {
+        ...parsedUrl.query,
+      },
     }
-    this.context.set(req, { pageChecks: {} })
 
-    try {
-      // Create a deep copy of the parsed URL.
-      const parsedUrlUpdated = {
-        ...parsedUrl,
-        query: {
-          ...parsedUrl.query,
-        },
+    for (const route of this.compiledRoutes) {
+      // only process rewrites for upgrade request
+      if (upgradeHead && route.type !== 'rewrite') {
+        continue
       }
 
-      for (const route of this.compiledRoutes) {
-        // only process rewrites for upgrade request
-        if (upgradeHead && route.type !== 'rewrite') {
-          continue
-        }
+      const originalPathname = parsedUrlUpdated.pathname!
+      const pathnameInfo = getNextPathnameInfo(originalPathname, {
+        nextConfig: this.nextConfig,
+        parseData: false,
+      })
+
+      if (
+        pathnameInfo.locale &&
+        !route.matchesLocaleAPIRoutes &&
+        isAPIRoute(pathnameInfo.pathname)
+      ) {
+        continue
+      }
 
-        const originalPathname = parsedUrlUpdated.pathname as string
-        const pathnameInfo = getNextPathnameInfo(originalPathname, {
-          nextConfig: this.nextConfig,
-          parseData: false,
-        })
+      if (getRequestMeta(req, '_nextHadBasePath')) {
+        pathnameInfo.basePath = this.basePath
+      }
 
-        if (
-          pathnameInfo.locale &&
-          !route.matchesLocaleAPIRoutes &&
-          isAPIRoute(pathnameInfo.pathname)
-        ) {
-          continue
-        }
+      const basePath = pathnameInfo.basePath
+      if (!route.matchesBasePath) {
+        pathnameInfo.basePath = ''
+      }
 
-        if (getRequestMeta(req, '_nextHadBasePath')) {
-          pathnameInfo.basePath = this.basePath
-        }
+      if (
+        route.matchesLocale &&
+        parsedUrlUpdated.query.__nextLocale &&
+        !pathnameInfo.locale
+      ) {
+        pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale
+      }
+
+      if (
+        !route.matchesLocale &&
+        pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale &&
+        pathnameInfo.locale
+      ) {
+        pathnameInfo.locale = undefined
+      }
+
+      if (
+        route.matchesTrailingSlash &&
+        getRequestMeta(req, '__nextHadTrailingSlash')
+      ) {
+        pathnameInfo.trailingSlash = true
+      }
 
-        const basePath = pathnameInfo.basePath
-        if (!route.matchesBasePath) {
-          pathnameInfo.basePath = ''
+      const matchPathname = formatNextPathnameInfo({
+        ignorePrefix: true,
+        ...pathnameInfo,
+      })
+
+      let params = route.match(matchPathname)
+      if ((route.has || route.missing) && params) {
+        const hasParams = matchHas(
+          req,
+          parsedUrlUpdated.query,
+          route.has,
+          route.missing
+        )
+        if (hasParams) {
+          Object.assign(params, hasParams)
+        } else {
+          params = false
         }
+      }
 
-        if (
-          route.matchesLocale &&
-          parsedUrlUpdated.query.__nextLocale &&
-          !pathnameInfo.locale
-        ) {
-          pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale
+      /**
+       * If it is a matcher that doesn't match the basePath (like the public
+       * directory) but Next.js is configured to use a basePath that was
+       * never there, we consider this an invalid match and keep routing.
+       */
+      if (
+        params &&
+        this.basePath &&
+        !route.matchesBasePath &&
+        !getRequestMeta(req, '_nextDidRewrite') &&
+        !basePath
+      ) {
+        continue
+      }
+
+      if (params) {
+        const isNextDataNormalizing = route.name === '_next/data normalizing'
+
+        if (isNextDataNormalizing) {
+          addRequestMeta(req, '_nextDataNormalizing', true)
         }
+        parsedUrlUpdated.pathname = matchPathname
+        const result = await route.fn(
+          req,
+          res,
+          params,
+          parsedUrlUpdated,
+          upgradeHead
+        )
 
-        if (
-          !route.matchesLocale &&
-          pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale &&
-          pathnameInfo.locale
-        ) {
-          pathnameInfo.locale = undefined
+        if (isNextDataNormalizing) {
+          addRequestMeta(req, '_nextDataNormalizing', false)
+        }
+        if (result.finished) {
+          return true
         }
 
-        if (
-          route.matchesTrailingSlash &&
-          getRequestMeta(req, '__nextHadTrailingSlash')
-        ) {
-          pathnameInfo.trailingSlash = true
+        if (result.pathname) {
+          parsedUrlUpdated.pathname = result.pathname
+        } else {
+          // since the fs route didn't finish routing we need to re-add the
+          // basePath to continue checking with the basePath present
+          parsedUrlUpdated.pathname = originalPathname
         }
 
-        const matchPathname = formatNextPathnameInfo({
-          ignorePrefix: true,
-          ...pathnameInfo,
-        })
-
-        let params = route.match(matchPathname)
-        if ((route.has || route.missing) && params) {
-          const hasParams = matchHas(
-            req,
-            parsedUrlUpdated.query,
-            route.has,
-            route.missing
-          )
-          if (hasParams) {
-            Object.assign(params, hasParams)
-          } else {
-            params = false
+        if (result.query) {
+          parsedUrlUpdated.query = {
+            ...getNextInternalQuery(parsedUrlUpdated.query),
+            ...result.query,
           }
         }
 
-        /**
-         * If it is a matcher that doesn't match the basePath (like the public
-         * directory) but Next.js is configured to use a basePath that was
-         * never there, we consider this an invalid match and keep routing.
-         */
+        // check filesystem
         if (
-          params &&
-          this.basePath &&
-          !route.matchesBasePath &&
-          !getRequestMeta(req, '_nextDidRewrite') &&
-          !basePath
+          route.check &&
+          (await this.checkFsRoutes(req, res, parsedUrlUpdated))
         ) {
-          continue
-        }
-
-        if (params) {
-          const isNextDataNormalizing = route.name === '_next/data normalizing'
-
-          if (isNextDataNormalizing) {
-            addRequestMeta(req, '_nextDataNormalizing', true)
-          }
-          parsedUrlUpdated.pathname = matchPathname
-          const result = await route.fn(
-            req,
-            res,
-            params,
-            parsedUrlUpdated,
-            upgradeHead
-          )
-
-          if (isNextDataNormalizing) {
-            addRequestMeta(req, '_nextDataNormalizing', false)
-          }
-          if (result.finished) {
-            return true
-          }
-
-          if (result.pathname) {
-            parsedUrlUpdated.pathname = result.pathname
-          } else {
-            // since the fs route didn't finish routing we need to re-add the
-            // basePath to continue checking with the basePath present
-            parsedUrlUpdated.pathname = originalPathname
-          }
-
-          if (result.query) {
-            parsedUrlUpdated.query = {
-              ...getNextInternalQuery(parsedUrlUpdated.query),
-              ...result.query,
-            }
-          }
-
-          // check filesystem
-          if (
-            route.check &&
-            (await this.checkFsRoutes(req, res, parsedUrlUpdated))
-          ) {
-            return true
-          }
+          return true
         }
       }
-
-      // All routes were tested, none were found.
-      return false
-    } finally {
-      this.context.delete(req)
     }
+
+    // All routes were tested, none were found.
+    return false
   }
 }
 
diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts
index 727f01763e1c7..cad09fed3502f 100644
--- a/packages/next/src/server/web-server.ts
+++ b/packages/next/src/server/web-server.ts
@@ -5,8 +5,7 @@ import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta'
 import type { Params } from '../shared/lib/router/utils/route-matcher'
 import type { PayloadOptions } from './send-payload'
 import type { LoadComponentsReturnType } from './load-components'
-import type { DynamicRoutes, PageChecker, Route } from './router'
-import type { NextConfig } from './config-shared'
+import type { Route, RouterOptions } from './router'
 import type { BaseNextRequest, BaseNextResponse } from './base-http'
 import type { UrlWithParsedQuery } from 'url'
 
@@ -27,7 +26,6 @@ import {
   normalizeVercelUrl,
 } from '../build/webpack/loaders/next-serverless-loader/utils'
 import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
-
 interface WebServerOptions extends Options {
   webServerConfig: {
     page: string
@@ -149,22 +147,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
       .fontLoaderManifest
   }
 
-  protected generateRoutes(): {
-    headers: Route[]
-    rewrites: {
-      beforeFiles: Route[]
-      afterFiles: Route[]
-      fallback: Route[]
-    }
-    fsRoutes: Route[]
-    redirects: Route[]
-    catchAllRoute: Route
-    catchAllMiddleware: Route[]
-    pageChecker: PageChecker
-    useFileSystemPublicRoutes: boolean
-    dynamicRoutes: DynamicRoutes | undefined
-    nextConfig: NextConfig
-  } {
+  protected generateRoutes(): RouterOptions {
     const fsRoutes: Route[] = [
       {
         match: getPathMatch('/_next/data/:path*'),
@@ -301,7 +284,6 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
           )
 
           if (localePathResult.detectedLocale) {
-            pathname = localePathResult.pathname
             parsedUrl.query.__nextLocale = localePathResult.detectedLocale
           }
         }
@@ -332,7 +314,6 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
 
     if (useFileSystemPublicRoutes) {
       this.appPathRoutes = this.getAppPathRoutes()
-      this.dynamicRoutes = this.getDynamicRoutes()
     }
 
     return {
@@ -347,8 +328,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
       catchAllRoute,
       catchAllMiddleware: [],
       useFileSystemPublicRoutes,
-      dynamicRoutes: this.dynamicRoutes,
-      pageChecker: this.hasPage.bind(this),
+      matchers: this.matchers,
       nextConfig: this.nextConfig,
     }
   }
@@ -357,6 +337,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> {
   protected async handleApiRequest() {
     return false
   }
+
   protected async renderHTML(
     req: WebNextRequest,
     _res: WebNextResponse,
diff --git a/packages/next/src/server/web/http.ts b/packages/next/src/server/web/http.ts
new file mode 100644
index 0000000000000..e984ed0899cd9
--- /dev/null
+++ b/packages/next/src/server/web/http.ts
@@ -0,0 +1,30 @@
+/**
+ * List of valid HTTP methods that can be implemented by Next.js's Custom App
+ * Routes.
+ */
+export const HTTP_METHODS = [
+  'GET',
+  'HEAD',
+  'OPTIONS',
+  'POST',
+  'PUT',
+  'DELETE',
+  'PATCH',
+] as const
+
+/**
+ * A type representing the valid HTTP methods that can be implemented by
+ * Next.js's Custom App Routes.
+ */
+export type HTTP_METHOD = typeof HTTP_METHODS[number]
+
+/**
+ * Checks to see if the passed string is an HTTP method. Note that this is case
+ * sensitive.
+ *
+ * @param maybeMethod the string that may be an HTTP method
+ * @returns true if the string is an HTTP method
+ */
+export function isHTTPMethod(maybeMethod: string): maybeMethod is HTTP_METHOD {
+  return HTTP_METHODS.includes(maybeMethod as HTTP_METHOD)
+}
diff --git a/packages/next/src/server/web/types.ts b/packages/next/src/server/web/types.ts
index 56ca53e02c5e4..9aec76179da2d 100644
--- a/packages/next/src/server/web/types.ts
+++ b/packages/next/src/server/web/types.ts
@@ -2,7 +2,7 @@ import type { I18NConfig } from '../config-shared'
 import type { NextRequest } from '../web/spec-extension/request'
 import type { NextFetchEvent } from '../web/spec-extension/fetch-event'
 import type { NextResponse } from './spec-extension/response'
-import type { ClonableBody } from '../body-streams'
+import type { CloneableBody } from '../body-streams'
 
 export interface NodeHeaders {
   [header: string]: string | string[] | undefined
@@ -33,7 +33,7 @@ export interface RequestData {
 }
 
 export type NodejsRequestData = Omit<RequestData, 'body'> & {
-  body?: ClonableBody
+  body?: CloneableBody
 }
 
 export interface FetchEventResult {
diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts
index 555ac48c848b6..9ec464023070c 100644
--- a/packages/next/src/shared/lib/constants.ts
+++ b/packages/next/src/shared/lib/constants.ts
@@ -63,12 +63,12 @@ export const MODERN_BROWSERSLIST_TARGET = [
 export const NEXT_BUILTIN_DOCUMENT = '__NEXT_BUILTIN_DOCUMENT__'
 export const NEXT_CLIENT_SSR_ENTRY_SUFFIX = '.__sc_client__'
 
-// server/flight-manifest.js
-export const FLIGHT_MANIFEST = 'flight-manifest'
-// server/flight-server-css-manifest.json
+// server/client-reference-manifest
+export const CLIENT_REFERENCE_MANIFEST = 'client-reference-manifest'
+// server/flight-server-css-manifest
 export const FLIGHT_SERVER_CSS_MANIFEST = 'flight-server-css-manifest'
-// server/actions-manifest.json
-export const ACTIONS_MANIFEST = 'actions-manifest'
+// server/server-reference-manifest
+export const SERVER_REFERENCE_MANIFEST = 'server-reference-manifest'
 // server/middleware-build-manifest.js
 export const MIDDLEWARE_BUILD_MANIFEST = 'middleware-build-manifest'
 // server/middleware-react-loadable-manifest.js
diff --git a/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts b/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts
index 1e1757b98dc3e..e79f56484c4c0 100644
--- a/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts
+++ b/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts
@@ -14,7 +14,7 @@ import { normalizePathSep } from './normalize-path-sep'
 export function removePagePathTail(
   pagePath: string,
   options: {
-    extensions: string[]
+    extensions: ReadonlyArray<string>
     keepIndex?: boolean
   }
 ) {
diff --git a/packages/next/src/shared/lib/router/utils/app-paths.ts b/packages/next/src/shared/lib/router/utils/app-paths.ts
index 3488d11e9300d..705ca6ac426c3 100644
--- a/packages/next/src/shared/lib/router/utils/app-paths.ts
+++ b/packages/next/src/shared/lib/router/utils/app-paths.ts
@@ -1,25 +1,53 @@
-// remove (name) from pathname as it's not considered for routing
-export function normalizeAppPath(pathname: string) {
-  return pathname.split('/').reduce((acc, segment, index, segments) => {
-    // Empty segments are ignored.
-    if (!segment) {
-      return acc
-    }
+import { ensureLeadingSlash } from '../../page-path/ensure-leading-slash'
 
-    if (segment.startsWith('(') && segment.endsWith(')')) {
-      return acc
-    }
+/**
+ * Normalizes an app route so it represents the actual request path. Essentially
+ * performing the following transformations:
+ *
+ * - `/(dashboard)/user/[id]/page` to `/user/[id]`
+ * - `/(dashboard)/account/page` to `/account`
+ * - `/user/[id]/page` to `/user/[id]`
+ * - `/account/page` to `/account`
+ * - `/page` to `/`
+ * - `/(dashboard)/user/[id]/route` to `/user/[id]`
+ * - `/(dashboard)/account/route` to `/account`
+ * - `/user/[id]/route` to `/user/[id]`
+ * - `/account/route` to `/account`
+ * - `/route` to `/`
+ * - `/` to `/`
+ *
+ * @param route the app route to normalize
+ * @returns the normalized pathname
+ */
+export function normalizeAppPath(route: string) {
+  return ensureLeadingSlash(
+    route.split('/').reduce((pathname, segment, index, segments) => {
+      // Empty segments are ignored.
+      if (!segment) {
+        return pathname
+      }
 
-    if (segment.startsWith('@')) {
-      return acc
-    }
+      // Groups are ignored.
+      if (segment.startsWith('(') && segment.endsWith(')')) {
+        return pathname
+      }
 
-    if (segment === 'page' && index === segments.length - 1) {
-      return acc
-    }
+      // Parallel segments are ignored.
+      if (segment.startsWith('@')) {
+        return pathname
+      }
 
-    return acc + `/${segment}`
-  }, '')
+      // The last segment (if it's a leaf) should be ignored.
+      if (
+        (segment === 'page' || segment === 'route') &&
+        index === segments.length - 1
+      ) {
+        return pathname
+      }
+
+      return `${pathname}/${segment}`
+    }, '')
+  )
 }
 
 export function normalizeRscPath(pathname: string, enabled?: boolean) {
diff --git a/packages/next/src/shared/lib/router/utils/route-matcher.ts b/packages/next/src/shared/lib/router/utils/route-matcher.ts
index bdc074c955263..67f9094209671 100644
--- a/packages/next/src/shared/lib/router/utils/route-matcher.ts
+++ b/packages/next/src/shared/lib/router/utils/route-matcher.ts
@@ -1,7 +1,7 @@
 import type { RouteRegex } from './route-regex'
 import { DecodeError } from '../../utils'
 
-export interface RouteMatch {
+export interface RouteMatchFn {
   (pathname: string | null | undefined): false | Params
 }
 
@@ -9,7 +9,7 @@ export interface Params {
   [param: string]: any
 }
 
-export function getRouteMatcher({ re, groups }: RouteRegex): RouteMatch {
+export function getRouteMatcher({ re, groups }: RouteRegex): RouteMatchFn {
   return (pathname: string | null | undefined) => {
     const routeMatch = re.exec(pathname!)
     if (!routeMatch) {
diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts
index ddac2c3807fd6..c486851159821 100644
--- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts
+++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts
@@ -785,18 +785,6 @@ for (const variant of ['default', 'turbo']) {
           .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]')
           .click()
 
-        const collapsedFrameworkGroups = await browser.elementsByCss(
-          "[data-nextjs-call-stack-framework-button][data-state='closed']"
-        )
-        for (const collapsedFrameworkButton of collapsedFrameworkGroups) {
-          // Open the collapsed framework groups, the callstack count should increase with each opened group
-          const callStackCountBeforeGroupOpened = await getCallStackCount()
-          await collapsedFrameworkButton.click()
-          expect(await getCallStackCount()).toBeGreaterThan(
-            callStackCountBeforeGroupOpened
-          )
-        }
-
         // Expect more than the default amount of frames
         // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements
         expect(await getCallStackCount()).toBeGreaterThan(9)
diff --git a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts
new file mode 100644
index 0000000000000..398e3f08bb590
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts
@@ -0,0 +1,300 @@
+import { createNextDescribe } from 'e2e-utils'
+import {
+  withRequestMeta,
+  getRequestMeta,
+  cookieWithRequestMeta,
+} from './helpers'
+import { Readable } from 'stream'
+
+createNextDescribe(
+  'app-custom-routes',
+  {
+    files: __dirname,
+  },
+  ({ next }) => {
+    describe('basic fetch request with a response', () => {
+      describe.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])(
+        'made via a %s request',
+        (method) => {
+          it.each(['/basic/endpoint', '/basic/vercel/endpoint'])(
+            'responds correctly on %s',
+            async (path) => {
+              const res = await next.fetch(path, { method })
+
+              expect(res.status).toEqual(200)
+              expect(await res.text()).toContain('hello, world')
+
+              const meta = getRequestMeta(res.headers)
+              expect(meta.method).toEqual(method)
+            }
+          )
+        }
+      )
+
+      describe('route groups', () => {
+        it('routes to the correct handler', async () => {
+          const res = await next.fetch('/basic/endpoint/nested')
+
+          expect(res.status).toEqual(200)
+          const meta = getRequestMeta(res.headers)
+          expect(meta.pathname).toEqual('/basic/endpoint/nested')
+        })
+      })
+
+      describe('request', () => {
+        it('can read query parameters', async () => {
+          const res = await next.fetch('/advanced/query?ping=pong')
+
+          expect(res.status).toEqual(200)
+          const meta = getRequestMeta(res.headers)
+          expect(meta.ping).toEqual('pong')
+        })
+      })
+
+      describe('response', () => {
+        // TODO-APP: re-enable when rewrites are supported again
+        it.skip('supports the NextResponse.rewrite() helper', async () => {
+          const res = await next.fetch('/hooks/rewrite')
+
+          expect(res.status).toEqual(200)
+
+          // This is running in the edge runtime, so we expect not to see this
+          // header.
+          expect(res.headers.has('x-middleware-rewrite')).toBeFalse()
+          expect(await res.text()).toContain('hello, world')
+        })
+
+        it('supports the NextResponse.redirect() helper', async () => {
+          const res = await next.fetch('/hooks/redirect/response', {
+            // "Manually" perform the redirect, we want to inspect the
+            // redirection response, so don't actually follow it.
+            redirect: 'manual',
+          })
+
+          expect(res.status).toEqual(307)
+          expect(res.headers.get('location')).toEqual('https://nextjs.org/')
+          expect(await res.text()).toBeEmpty()
+        })
+
+        it('supports the NextResponse.json() helper', async () => {
+          const meta = { ping: 'pong' }
+          const res = await next.fetch('/hooks/json', {
+            headers: withRequestMeta(meta),
+          })
+
+          expect(res.status).toEqual(200)
+          expect(res.headers.get('content-type')).toEqual('application/json')
+          expect(await res.json()).toEqual(meta)
+        })
+      })
+    })
+
+    describe('body', () => {
+      it('can handle handle a streaming request and streaming response', async () => {
+        const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
+        let index = 0
+        const stream = new Readable({
+          read() {
+            if (index >= body.length) return this.push(null)
+
+            this.push(body[index] + '\n')
+            index++
+          },
+        })
+
+        const res = await next.fetch('/advanced/body/streaming', {
+          method: 'POST',
+          body: stream,
+        })
+
+        expect(res.status).toEqual(200)
+        expect(await res.text()).toEqual(body.join('\n') + '\n')
+      })
+
+      it('can read a JSON encoded body', async () => {
+        const body = { ping: 'pong' }
+        const res = await next.fetch('/advanced/body/json', {
+          method: 'POST',
+          body: JSON.stringify(body),
+        })
+
+        expect(res.status).toEqual(200)
+        const meta = getRequestMeta(res.headers)
+        expect(meta.body).toEqual(body)
+      })
+
+      it('can read a streamed JSON encoded body', async () => {
+        const body = { ping: 'pong' }
+        const encoded = JSON.stringify(body)
+        let index = 0
+        const stream = new Readable({
+          async read() {
+            if (index >= encoded.length) return this.push(null)
+
+            this.push(encoded[index])
+            index++
+          },
+        })
+        const res = await next.fetch('/advanced/body/json', {
+          method: 'POST',
+          body: stream,
+        })
+
+        expect(res.status).toEqual(200)
+        const meta = getRequestMeta(res.headers)
+        expect(meta.body).toEqual(body)
+      })
+
+      it('can read the text body', async () => {
+        const body = 'hello, world'
+        const res = await next.fetch('/advanced/body/text', {
+          method: 'POST',
+          body,
+        })
+
+        expect(res.status).toEqual(200)
+        const meta = getRequestMeta(res.headers)
+        expect(meta.body).toEqual(body)
+      })
+    })
+
+    describe('context', () => {
+      it('provides params to routes with dynamic parameters', async () => {
+        const res = await next.fetch('/basic/vercel/endpoint')
+
+        expect(res.status).toEqual(200)
+        const meta = getRequestMeta(res.headers)
+        expect(meta.params).toEqual({ tenantID: 'vercel' })
+      })
+
+      it('provides params to routes with catch-all routes', async () => {
+        const res = await next.fetch('/basic/vercel/some/other/resource')
+
+        expect(res.status).toEqual(200)
+        const meta = getRequestMeta(res.headers)
+        expect(meta.params).toEqual({
+          tenantID: 'vercel',
+          resource: ['some', 'other', 'resource'],
+        })
+      })
+
+      it('does not provide params to routes without dynamic parameters', async () => {
+        const res = await next.fetch('/basic/endpoint')
+
+        expect(res.ok).toBeTrue()
+
+        const meta = getRequestMeta(res.headers)
+        expect(meta.params).toEqual(null)
+      })
+    })
+
+    describe('hooks', () => {
+      describe('headers', () => {
+        it('gets the correct values', async () => {
+          const res = await next.fetch('/hooks/headers', {
+            headers: withRequestMeta({ ping: 'pong' }),
+          })
+
+          expect(res.status).toEqual(200)
+
+          const meta = getRequestMeta(res.headers)
+          expect(meta.ping).toEqual('pong')
+        })
+      })
+
+      describe('cookies', () => {
+        it('gets the correct values', async () => {
+          const res = await next.fetch('/hooks/cookies', {
+            headers: cookieWithRequestMeta({ ping: 'pong' }),
+          })
+
+          expect(res.status).toEqual(200)
+
+          const meta = getRequestMeta(res.headers)
+          expect(meta.ping).toEqual('pong')
+        })
+      })
+
+      describe('redirect', () => {
+        it('can respond correctly', async () => {
+          const res = await next.fetch('/hooks/redirect', {
+            // "Manually" perform the redirect, we want to inspect the
+            // redirection response, so don't actually follow it.
+            redirect: 'manual',
+          })
+
+          expect(res.status).toEqual(302)
+          expect(res.headers.get('location')).toEqual('https://nextjs.org/')
+          expect(await res.text()).toBeEmpty()
+        })
+      })
+
+      describe('notFound', () => {
+        it('can respond correctly', async () => {
+          const res = await next.fetch('/hooks/not-found')
+
+          expect(res.status).toEqual(404)
+          expect(await res.text()).toBeEmpty()
+        })
+      })
+    })
+
+    describe('error conditions', () => {
+      it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => {
+        const res = await next.fetch('/status/405', { method: 'HEADER' })
+
+        expect(res.status).toEqual(400)
+        expect(await res.text()).toBeEmpty()
+      })
+
+      it('responds with 405 (Method Not Allowed) when method is not implemented', async () => {
+        const res = await next.fetch('/status/405', { method: 'POST' })
+
+        expect(res.status).toEqual(405)
+        expect(await res.text()).toBeEmpty()
+      })
+
+      it('responds with 500 (Internal Server Error) when the handler throws an error', async () => {
+        const res = await next.fetch('/status/500')
+
+        expect(res.status).toEqual(500)
+        expect(await res.text()).toBeEmpty()
+      })
+
+      it('responds with 500 (Internal Server Error) when the handler calls NextResponse.next()', async () => {
+        const error =
+          'https://nextjs.org/docs/messages/next-response-next-in-app-route-handler'
+
+        // Precondition. We shouldn't have seen this before. This ensures we're
+        // testing that the specific route throws this error in the console.
+        expect(next.cliOutput).not.toContain(error)
+
+        const res = await next.fetch('/status/500/next')
+
+        expect(res.status).toEqual(500)
+        expect(await res.text()).toBeEmpty()
+        expect(next.cliOutput).toContain(error)
+      })
+    })
+
+    describe('automatic implementations', () => {
+      it('implements HEAD on routes with GET already implemented', async () => {
+        const res = await next.fetch('/methods/head', { method: 'HEAD' })
+
+        expect(res.status).toEqual(200)
+        expect(await res.text()).toBeEmpty()
+      })
+
+      it('implements OPTIONS on routes', async () => {
+        const res = await next.fetch('/methods/options', { method: 'OPTIONS' })
+
+        expect(res.status).toEqual(204)
+        expect(await res.text()).toBeEmpty()
+
+        expect(res.headers.get('allow')).toEqual(
+          'DELETE, GET, HEAD, OPTIONS, POST'
+        )
+      })
+    })
+  }
+)
diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts
new file mode 100644
index 0000000000000..421d40e661af5
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts
@@ -0,0 +1,10 @@
+import type { NextRequest } from 'next/server'
+import { withRequestMeta } from '../../../../helpers'
+
+export async function POST(request: NextRequest) {
+  const body = await request.json()
+  return new Response('hello, world', {
+    status: 200,
+    headers: withRequestMeta({ body }),
+  })
+}
diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts
new file mode 100644
index 0000000000000..7ab8d8c5bc686
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts
@@ -0,0 +1,25 @@
+import type { NextRequest } from 'next/server'
+
+export async function POST(request: NextRequest) {
+  const reader = request.body?.getReader()
+  if (!reader) {
+    return new Response(null, { status: 400, statusText: 'Bad Request' })
+  }
+
+  // Readable stream here is polyfilled from the Fetch API (from undici).
+  const stream = new ReadableStream({
+    async pull(controller) {
+      // Read the next chunk from the stream.
+      const { value, done } = await reader.read()
+      if (done) {
+        // Finish the stream.
+        return controller.close()
+      }
+
+      // Add the request value to the response stream.
+      controller.enqueue(value)
+    },
+  })
+
+  return new Response(stream, { status: 200 })
+}
diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts
new file mode 100644
index 0000000000000..07b50713feeea
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts
@@ -0,0 +1,10 @@
+import type { NextRequest } from 'next/server'
+import { withRequestMeta } from '../../../../helpers'
+
+export async function POST(request: NextRequest) {
+  const body = await request.text()
+  return new Response('hello, world', {
+    status: 200,
+    headers: withRequestMeta({ body }),
+  })
+}
diff --git a/test/e2e/app-dir/app-routes/app/advanced/query/route.ts b/test/e2e/app-dir/app-routes/app/advanced/query/route.ts
new file mode 100644
index 0000000000000..4ecb7198a8223
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/advanced/query/route.ts
@@ -0,0 +1,11 @@
+import { withRequestMeta } from '../../../helpers'
+
+export async function GET(request: Request): Promise<Response> {
+  const { searchParams } = new URL(request.url)
+
+  return new Response('hello, world', {
+    headers: withRequestMeta({
+      ping: searchParams.get('ping'),
+    }),
+  })
+}
diff --git a/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts b/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts
new file mode 100644
index 0000000000000..84976ebe67a77
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts
@@ -0,0 +1 @@
+export * from '../../../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts
new file mode 100644
index 0000000000000..ef4be6fa84809
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts
@@ -0,0 +1 @@
+export * from '../../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts
new file mode 100644
index 0000000000000..ef4be6fa84809
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts
@@ -0,0 +1 @@
+export * from '../../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts b/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts
new file mode 100644
index 0000000000000..a950beba77447
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts
@@ -0,0 +1 @@
+export * from '../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts b/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts
new file mode 100644
index 0000000000000..eba4377462327
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts
@@ -0,0 +1,14 @@
+import { cookies } from 'next/headers'
+import { getRequestMeta, withRequestMeta } from '../../../helpers'
+
+export async function GET() {
+  const c = cookies()
+
+  // Put the request meta in the response directly as meta again.
+  const meta = getRequestMeta(c)
+
+  return new Response(null, {
+    status: 200,
+    headers: withRequestMeta(meta),
+  })
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts
new file mode 100644
index 0000000000000..23ecc800a23b3
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts
@@ -0,0 +1,14 @@
+import { headers } from 'next/headers'
+import { getRequestMeta, withRequestMeta } from '../../../helpers'
+
+export async function GET() {
+  const h = headers()
+
+  // Put the request meta in the response directly as meta again.
+  const meta = getRequestMeta(h)
+
+  return new Response(null, {
+    status: 200,
+    headers: withRequestMeta(meta),
+  })
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/json/route.ts b/test/e2e/app-dir/app-routes/app/hooks/json/route.ts
new file mode 100644
index 0000000000000..624e22aca707b
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/json/route.ts
@@ -0,0 +1,7 @@
+import { getRequestMeta } from '../../../helpers'
+import { NextResponse } from 'next/server'
+
+export async function GET(request: Request) {
+  const meta = getRequestMeta(request.headers)
+  return NextResponse.json(meta)
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts b/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts
new file mode 100644
index 0000000000000..6a41a17bc7a46
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts
@@ -0,0 +1,5 @@
+import { notFound } from 'next/navigation'
+
+export async function GET() {
+  notFound()
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts b/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts
new file mode 100644
index 0000000000000..f8dc12de59846
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server'
+
+export async function GET() {
+  return NextResponse.redirect('https://nextjs.org/')
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts b/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts
new file mode 100644
index 0000000000000..d4a1811603adb
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts
@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation'
+
+export async function GET() {
+  redirect('https://nextjs.org/')
+}
diff --git a/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts b/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts
new file mode 100644
index 0000000000000..d3ce936274210
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts
@@ -0,0 +1,6 @@
+import { type NextRequest, NextResponse } from 'next/server'
+
+export async function GET(request: NextRequest) {
+  const url = new URL('/basic/endpoint', request.nextUrl)
+  return NextResponse.rewrite(url)
+}
diff --git a/test/e2e/app-dir/app-routes/app/methods/head/route.ts b/test/e2e/app-dir/app-routes/app/methods/head/route.ts
new file mode 100644
index 0000000000000..7a0af7c5f337a
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/methods/head/route.ts
@@ -0,0 +1,3 @@
+// This route only exports GET, and not HEAD. The test verifies that a request
+// via HEAD will be the same as GET but without the response body.
+export { GET } from '../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/methods/options/route.ts b/test/e2e/app-dir/app-routes/app/methods/options/route.ts
new file mode 100644
index 0000000000000..1c6e54400c072
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/methods/options/route.ts
@@ -0,0 +1,3 @@
+// This route exports GET, POST, and DELETE. The test verifies that this route
+// will handle the OPTIONS request.
+export { GET, POST, DELETE } from '../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/status/405/route.ts b/test/e2e/app-dir/app-routes/app/status/405/route.ts
new file mode 100644
index 0000000000000..1123d7ebb9980
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/status/405/route.ts
@@ -0,0 +1 @@
+export { GET } from '../../../handlers/hello'
diff --git a/test/e2e/app-dir/app-routes/app/status/500/next/route.ts b/test/e2e/app-dir/app-routes/app/status/500/next/route.ts
new file mode 100644
index 0000000000000..445bbb15cb753
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/status/500/next/route.ts
@@ -0,0 +1,5 @@
+import { NextResponse } from 'next/server'
+
+export async function GET() {
+  return NextResponse.next()
+}
diff --git a/test/e2e/app-dir/app-routes/app/status/500/route.ts b/test/e2e/app-dir/app-routes/app/status/500/route.ts
new file mode 100644
index 0000000000000..123adc390e424
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/app/status/500/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+  throw new Error('this is a runtime error')
+}
diff --git a/test/e2e/app-dir/app-routes/handlers/hello.ts b/test/e2e/app-dir/app-routes/handlers/hello.ts
new file mode 100644
index 0000000000000..9d84fd0810378
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/handlers/hello.ts
@@ -0,0 +1,25 @@
+import { type NextRequest } from 'next/server'
+import { withRequestMeta } from '../helpers'
+
+export const helloHandler = async (
+  request: NextRequest,
+  { params }: { params?: Record<string, string | string[]> }
+): Promise<Response> => {
+  const { pathname } = new URL(request.url)
+
+  return new Response('hello, world', {
+    headers: withRequestMeta({
+      method: request.method,
+      params: params ?? null,
+      pathname,
+    }),
+  })
+}
+
+export const GET = helloHandler
+export const HEAD = helloHandler
+export const OPTIONS = helloHandler
+export const POST = helloHandler
+export const PUT = helloHandler
+export const DELETE = helloHandler
+export const PATCH = helloHandler
diff --git a/test/e2e/app-dir/app-routes/helpers.ts b/test/e2e/app-dir/app-routes/helpers.ts
new file mode 100644
index 0000000000000..08739f879b5fe
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/helpers.ts
@@ -0,0 +1,69 @@
+const KEY = 'x-request-meta'
+
+/**
+ * Adds a new header to the headers object and serializes it. To be used in
+ * conjunction with the `getRequestMeta` function in tests to verify request
+ * data from the handler.
+ *
+ * @param meta metadata to inject into the headers
+ * @param headers the existing headers on the response to merge with
+ * @returns the merged headers with the request meta added
+ */
+export function withRequestMeta(
+  meta: Record<string, any>,
+  headers: Record<string, string> = {}
+): Record<string, string> {
+  return {
+    ...headers,
+    [KEY]: JSON.stringify(meta),
+  }
+}
+
+/**
+ * Adds a cookie to the headers with the provided request metadata. Existing
+ * cookies will be merged, but it will not merge request metadata that already
+ * exists on an existing cookie.
+ *
+ * @param meta metadata to inject into the headers via a cookie
+ * @param headers the existing headers on the response to merge with
+ * @returns the merged headers with the request meta added as a cookie
+ */
+export function cookieWithRequestMeta(
+  meta: Record<string, any>,
+  { cookie = '', ...headers }: Record<string, string> = {}
+): Record<string, string> {
+  if (cookie) cookie += '; '
+
+  // We encode this with `btoa` because the JSON string can contain characters
+  // that are invalid in a cookie value.
+  cookie += `${KEY}=${btoa(JSON.stringify(meta))}`
+
+  return {
+    ...headers,
+    cookie,
+  }
+}
+
+type Cookies = {
+  get(name: string): { name: string; value: string } | undefined
+}
+
+/**
+ * Gets request metadata from the response headers or cookie.
+ *
+ * @param headersOrCookies response headers from the request or cookies object
+ * @returns any injected metadata on the request
+ */
+export function getRequestMeta(
+  headersOrCookies: Headers | Cookies
+): Record<string, any> {
+  const headerOrCookie = headersOrCookies.get(KEY)
+  if (!headerOrCookie) return {}
+
+  // If the value is a string, then parse it now, it was headers.
+  if (typeof headerOrCookie === 'string') return JSON.parse(headerOrCookie)
+
+  // It's a cookie! Parse it now. The cookie value should be encoded with
+  // `btoa`, hence the use of `atob`.
+  return JSON.parse(atob(headerOrCookie.value))
+}
diff --git a/test/e2e/app-dir/app-routes/next.config.js b/test/e2e/app-dir/app-routes/next.config.js
new file mode 100644
index 0000000000000..cfa3ac3d7aa94
--- /dev/null
+++ b/test/e2e/app-dir/app-routes/next.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+  experimental: {
+    appDir: true,
+  },
+}
diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts
index cabb4b9a378be..9dea077ba9bf5 100644
--- a/test/e2e/app-dir/app/index.test.ts
+++ b/test/e2e/app-dir/app/index.test.ts
@@ -837,22 +837,19 @@ createNextDescribe(
           async (method) => {
             const browser = await next.browser('/internal')
 
-            try {
-              // Wait for and click the navigation element, this should trigger
-              // the flight request that'll be caught by the middleware. If the
-              // middleware sees any flight data on the request it'll redirect to
-              // a page with an element of #failure, otherwise, we'll see the
-              // element for #success.
-              await browser
-                .waitForElementByCss(`#navigate-${method}`)
-                .elementById(`navigate-${method}`)
-                .click()
-              expect(
-                await browser.waitForElementByCss('#success', 3000).text()
-              ).toBe('Success')
-            } finally {
-              await browser.close()
-            }
+            // Wait for and click the navigation element, this should trigger
+            // the flight request that'll be caught by the middleware. If the
+            // middleware sees any flight data on the request it'll redirect to
+            // a page with an element of #failure, otherwise, we'll see the
+            // element for #success.
+            await browser
+              .waitForElementByCss(`#navigate-${method}`)
+              .elementById(`navigate-${method}`)
+              .click()
+            await check(
+              async () => await browser.elementByCss('#success').text(),
+              /Success/
+            )
           }
         )
       })
diff --git a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts
index 314e932e7bef3..2faa0ec42c0d5 100644
--- a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts
+++ b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts
@@ -460,7 +460,7 @@ describe('app dir - rsc basics', () => {
       const files = [
         'middleware-build-manifest.js',
         'middleware-manifest.json',
-        'flight-manifest.json',
+        'client-reference-manifest.json',
       ]
 
       files.forEach((file) => {
diff --git a/test/integration/custom-error/test/index.test.js b/test/integration/custom-error/test/index.test.js
index a519dbd86dd12..48d2ed6dd4bc9 100644
--- a/test/integration/custom-error/test/index.test.js
+++ b/test/integration/custom-error/test/index.test.js
@@ -75,8 +75,8 @@ describe('Custom _error', () => {
     it('should warn on custom /_error without custom /404', async () => {
       stderr = ''
       const html = await renderViaHTTP(appPort, '/404')
-      expect(html).toContain('An error 404 occurred on server')
       expect(stderr).toMatch(customErrNo404Match)
+      expect(html).toContain('An error 404 occurred on server')
     })
   })
 
diff --git a/test/integration/custom-server/server.js b/test/integration/custom-server/server.js
index 1aa4f7141fdba..06ec26985bd0a 100644
--- a/test/integration/custom-server/server.js
+++ b/test/integration/custom-server/server.js
@@ -1,5 +1,6 @@
 if (process.env.POLYFILL_FETCH) {
   global.fetch = require('node-fetch').default
+  global.Request = require('node-fetch').Request
 }
 
 const { readFileSync } = require('fs')
diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js
index c702c11e8fd36..8fa302852e999 100644
--- a/test/integration/custom-server/test/index.test.js
+++ b/test/integration/custom-server/test/index.test.js
@@ -280,7 +280,7 @@ describe.each([
       expect(stderr).toContain(
         'error - unhandledRejection: Error: unhandled rejection'
       )
-      expect(stderr).toContain('server.js:31:22')
+      expect(stderr).toContain('server.js:32:22')
     })
   })
 
diff --git a/test/integration/i18n-support-base-path/test/index.test.js b/test/integration/i18n-support-base-path/test/index.test.js
index 55c9b99c9d8ce..0e99499c2b683 100644
--- a/test/integration/i18n-support-base-path/test/index.test.js
+++ b/test/integration/i18n-support-base-path/test/index.test.js
@@ -33,6 +33,13 @@ describe('i18n Support basePath', () => {
       )
     })
   })
+  afterAll(async () => {
+    await new Promise((resolve, reject) =>
+      ctx.externalApp.close((err) => {
+        err ? reject(err) : resolve()
+      })
+    )
+  })
 
   describe('dev mode', () => {
     const curCtx = {
diff --git a/test/integration/i18n-support/test/shared.js b/test/integration/i18n-support/test/shared.js
index cfc69fdda8b15..c199bbeabb780 100644
--- a/test/integration/i18n-support/test/shared.js
+++ b/test/integration/i18n-support/test/shared.js
@@ -1355,8 +1355,9 @@ export function runTests(ctx) {
     })
   })
 
-  it('should rewrite to API route correctly', async () => {
-    for (const locale of locales) {
+  it.each(locales)(
+    'should rewrite to API route correctly for %s locale',
+    async (locale) => {
       const res = await fetchViaHTTP(
         ctx.appPort,
         `${ctx.basePath || ''}${
@@ -1368,13 +1369,14 @@ export function runTests(ctx) {
         }
       )
 
+      expect(res.headers.get('content-type')).toContain('application/json')
       const data = await res.json()
       expect(data).toEqual({
         hello: true,
         query: {},
       })
     }
-  })
+  )
 
   it('should apply rewrites correctly', async () => {
     let res = await fetchViaHTTP(