diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index c92b838fa..e71ba78bf 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -24,6 +24,7 @@ const _pagesPageResponsePath = resolveEntryPath(
);
const _pagesPageDataPath = resolveEntryPath("../server/pages-page-data.js", import.meta.url);
const _pagesDataRoutePath = resolveEntryPath("../server/pages-data-route.js", import.meta.url);
+const _pagesDefault404Path = resolveEntryPath("../server/pages-default-404.js", import.meta.url);
const _pagesNodeCompatPath = resolveEntryPath("../server/pages-node-compat.js", import.meta.url);
const _pagesApiRoutePath = resolveEntryPath("../server/pages-api-route.js", import.meta.url);
const _isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url);
@@ -240,6 +241,7 @@ import {
import { getScriptNonceFromHeaderSources as __getScriptNonceFromHeaderSources } from ${JSON.stringify(_cspPath)};
import { resolvePagesPageData as __resolvePagesPageData } from ${JSON.stringify(_pagesPageDataPath)};
import { buildNextDataJsonResponse as __buildNextDataJsonResponse, buildNextDataNotFoundResponse as __buildNextDataNotFoundResponse, isNextDataPathname as __isNextDataPathname, parseNextDataPathname as __parseNextDataPathname } from ${JSON.stringify(_pagesDataRoutePath)};
+import { buildDefaultPagesNotFoundResponse as __buildDefaultPagesNotFoundResponse } from ${JSON.stringify(_pagesDefault404Path)};
import { renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)};
${instrumentationImportCode}
${middlewareImportCode}
@@ -563,8 +565,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
if (isDataReq) {
return __buildNextDataNotFoundResponse();
}
- return new Response("
404 - Page not found
",
- { status: 404, headers: { "Content-Type": "text/html" } });
+ return __buildDefaultPagesNotFoundResponse();
}
const { route, params } = match;
diff --git a/packages/vinext/src/server/app-fallback-renderer.ts b/packages/vinext/src/server/app-fallback-renderer.ts
index 704db7d11..7378c30be 100644
--- a/packages/vinext/src/server/app-fallback-renderer.ts
+++ b/packages/vinext/src/server/app-fallback-renderer.ts
@@ -6,6 +6,7 @@ import {
type AppPageBoundaryRoute,
} from "./app-page-boundary-render.js";
import { DEFAULT_GLOBAL_ERROR_MODULE } from "./default-global-error-module.js";
+import { DEFAULT_NOT_FOUND_MODULE } from "./default-not-found-module.js";
import type { AppPageFontPreload } from "./app-page-execution.js";
import type { AppPageMiddlewareContext } from "./app-page-response.js";
import type { AppPageSsrHandler } from "./app-page-stream.js";
@@ -140,6 +141,16 @@ export function createAppFallbackRenderer(
const effectiveGlobalErrorModule: TModule | null =
globalErrorModule ?? (DEFAULT_GLOBAL_ERROR_MODULE as unknown as TModule);
+ // When the app does not define `app/not-found.tsx` (and has not opted into
+ // `app/global-not-found.tsx`), fall back to vinext's built-in default
+ // not-found component so route-miss 404s render the canonical Next.js
+ // markup (status + "This page could not be found." message). Matches the
+ // default not-found UI shipped with Next.js's app loader.
+ // See packages/vinext/src/shims/default-not-found.tsx and
+ // packages/vinext/src/server/default-not-found-module.ts.
+ const effectiveRootNotFoundModule: TModule | null =
+ rootNotFoundModule ?? (DEFAULT_NOT_FOUND_MODULE as unknown as TModule);
+
return {
renderHttpAccessFallback(
route,
@@ -222,7 +233,7 @@ export function createAppFallbackRenderer(
resolveChildSegments,
rootForbiddenModule,
rootLayouts,
- rootNotFoundModule,
+ rootNotFoundModule: effectiveRootNotFoundModule,
rootUnauthorizedModule,
route,
renderToReadableStream: rscRenderer,
diff --git a/packages/vinext/src/server/default-not-found-module.ts b/packages/vinext/src/server/default-not-found-module.ts
new file mode 100644
index 000000000..62084bc4e
--- /dev/null
+++ b/packages/vinext/src/server/default-not-found-module.ts
@@ -0,0 +1,18 @@
+import DefaultNotFound from "vinext/shims/default-not-found";
+
+/**
+ * Module-shaped wrapper around vinext's built-in default not-found component.
+ * Used as the fallback when an app does not define its own `app/not-found.tsx`
+ * (and has not opted into `app/global-not-found.tsx`). The runtime treats any
+ * `{ default: Component }` record as a "not-found module", so wrapping the
+ * component this way lets us thread the default through the existing
+ * `rootNotFoundModule` plumbing without introducing a parallel code path.
+ *
+ * Mirrors Next.js's `defaultNotFoundPath`
+ * (`next/dist/client/components/builtin/not-found.js`), which is selected
+ * automatically when the user has not supplied a custom not-found file:
+ * https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-app-loader/index.ts
+ */
+export const DEFAULT_NOT_FOUND_MODULE = {
+ default: DefaultNotFound,
+} as const;
diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts
index 044702f92..fac0a63da 100644
--- a/packages/vinext/src/server/dev-server.ts
+++ b/packages/vinext/src/server/dev-server.ts
@@ -45,6 +45,7 @@ import {
parseCookieLocaleFromHeader,
resolvePagesI18nRequest,
} from "./pages-i18n.js";
+import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js";
/**
* Render a React element to a string using renderToReadableStream.
@@ -1305,7 +1306,21 @@ async function renderErrorPage(
}
}
- // No custom error page found — use plain text fallback
+ // No custom error page found — fall back to vinext's default. The 404 case
+ // renders the canonical Next.js HTML body (matching `pages/_error.tsx`) so
+ // dev-server responses include "This page could not be found." just like
+ // production. Other status codes keep the plain-text fallback because
+ // Next.js's `_error.tsx` defaults already handle those cases when present.
+ if (statusCode === 404) {
+ const defaultResponse = buildDefaultPagesNotFoundResponse();
+ const headers: Record = {};
+ defaultResponse.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+ res.writeHead(defaultResponse.status, headers);
+ res.end(await defaultResponse.text());
+ return;
+ }
res.writeHead(statusCode, { "Content-Type": "text/plain" });
- res.end(`${statusCode} - ${statusCode === 404 ? "Page not found" : "Internal Server Error"}`);
+ res.end(`${statusCode} - Internal Server Error`);
}
diff --git a/packages/vinext/src/server/pages-default-404.ts b/packages/vinext/src/server/pages-default-404.ts
new file mode 100644
index 000000000..f56e83958
--- /dev/null
+++ b/packages/vinext/src/server/pages-default-404.ts
@@ -0,0 +1,41 @@
+/**
+ * Default 404 HTML body for the Pages Router.
+ *
+ * Used when a Pages Router request does not match any route (and the app has
+ * not supplied a custom `pages/404.tsx`). Mirrors the markup Next.js's
+ * `pages/_error.tsx` produces for a 404 response: a centered status / message
+ * pair plus minified theme CSS and dark-mode media query. The message string
+ * `"This page could not be found."` (note the trailing period) is the
+ * canonical body asserted by Next.js's deploy suite
+ * (`test/e2e/getserversideprops/test/index.test.ts`,
+ * `test/e2e/basepath/error-pages.test.ts`).
+ *
+ * Kept as a hand-rendered HTML literal rather than a React-rendered template
+ * because the Pages Router server entry is invoked from both Workers and the
+ * dev server before any React-renderer wiring is available for this path —
+ * matching the lightweight build-time strategy Next.js uses for its packaged
+ * `_error` static fallback. See:
+ * .nextjs-ref/packages/next/src/pages/_error.tsx
+ */
+
+const STATUS = 404;
+const MESSAGE = "This page could not be found.";
+
+const CSS = `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}`;
+
+const HTML = `${STATUS}: ${MESSAGE}
${STATUS}
${MESSAGE}
`;
+
+/**
+ * Build the Next.js-compatible default 404 HTML response for the Pages Router.
+ * Content-type is `text/html; charset=utf-8`, matching Next.js's
+ * `pages-handler` 404 response.
+ */
+export function buildDefaultPagesNotFoundResponse(): Response {
+ return new Response(HTML, {
+ status: STATUS,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+}
+
+/** Exported for tests / callers that need the raw HTML body. */
+export const DEFAULT_PAGES_NOT_FOUND_HTML = HTML;
diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts
index 5c37408db..6ce15d3ea 100644
--- a/packages/vinext/src/server/pages-page-data.ts
+++ b/packages/vinext/src/server/pages-page-data.ts
@@ -10,6 +10,7 @@ import {
type PagesGsspResponse,
type PagesI18nRenderContext,
} from "./pages-page-response.js";
+import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js";
type PagesRedirectResult = {
destination: string;
@@ -163,10 +164,7 @@ type ResolvePagesPageDataResult =
| ResolvePagesPageDataResponseResult;
function buildPagesNotFoundResponse(): Response {
- return new Response("
404 - Page not found
", {
- status: 404,
- headers: { "Content-Type": "text/html" },
- });
+ return buildDefaultPagesNotFoundResponse();
}
function buildPagesDataNotFoundResponse(): Response {
diff --git a/packages/vinext/src/shims/default-not-found.tsx b/packages/vinext/src/shims/default-not-found.tsx
new file mode 100644
index 000000000..15aedd6c8
--- /dev/null
+++ b/packages/vinext/src/shims/default-not-found.tsx
@@ -0,0 +1,90 @@
+/**
+ * Ported from Next.js's built-in default not-found component:
+ * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/builtin/not-found.tsx
+ * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/http-access-fallback/error-fallback.tsx
+ *
+ * Rendered when an App Router request resolves to a 404 and the user has not
+ * supplied their own `app/not-found.tsx` (or `app/global-not-found.tsx`).
+ * Matches Next.js's `HTTPAccessErrorFallback` exactly: a centered 404 / message
+ * pair with minified theme CSS and dark-mode media query.
+ *
+ * The message string `"This page could not be found."` (note the trailing
+ * period) is the canonical body asserted by Next.js's deploy suite
+ * (`test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts`,
+ * `test/e2e/basepath/error-pages.test.ts`).
+ */
+import React from "react";
+
+const styles = {
+ error: {
+ fontFamily:
+ 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
+ height: "100vh",
+ textAlign: "center" as const,
+ display: "flex",
+ flexDirection: "column" as const,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ desc: {
+ display: "inline-block",
+ },
+ h1: {
+ display: "inline-block",
+ margin: "0 20px 0 0",
+ padding: "0 23px 0 0",
+ fontSize: 24,
+ fontWeight: 500,
+ verticalAlign: "top",
+ lineHeight: "49px",
+ },
+ h2: {
+ fontSize: 14,
+ fontWeight: 400,
+ lineHeight: "49px",
+ margin: 0,
+ },
+} satisfies Record;
+
+const STATUS = 404;
+const MESSAGE = "This page could not be found.";
+
+/**
+ * Mirrors ``
+ * from Next.js. Kept in sync with the upstream component's structure so HTML
+ * snapshot diffs between Next.js and vinext stay minimal.
+ */
+export default function DefaultNotFound(): React.ReactElement {
+ return React.createElement(
+ React.Fragment,
+ null,
+ React.createElement("title", null, `${STATUS}: ${MESSAGE}`),
+ React.createElement(
+ "div",
+ { style: styles.error },
+ React.createElement(
+ "div",
+ null,
+ React.createElement("style", {
+ dangerouslySetInnerHTML: {
+ __html:
+ "body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}",
+ },
+ }),
+ React.createElement(
+ "h1",
+ {
+ className: "next-error-h1",
+ style: styles.h1,
+ },
+ STATUS,
+ ),
+ React.createElement(
+ "div",
+ { style: styles.desc },
+ React.createElement("h2", { style: styles.h2 }, MESSAGE),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/tests/app-fallback-renderer.test.ts b/tests/app-fallback-renderer.test.ts
index 162a48679..77c06a1f7 100644
--- a/tests/app-fallback-renderer.test.ts
+++ b/tests/app-fallback-renderer.test.ts
@@ -491,6 +491,48 @@ describe("app fallback renderer default global error UI", () => {
});
});
+// Regression for #1454 — default App Router 404 must match Next.js's built-in
+// not-found component ("This page could not be found." with trailing period).
+describe("app fallback renderer default not-found UI", () => {
+ it("renders the canonical 'This page could not be found.' body when no not-found.tsx exists", async () => {
+ const { renderer } = createRenderer();
+ const request = new Request("https://example.com/missing");
+
+ const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
+ headers: null,
+ status: null,
+ });
+
+ expect(response?.status).toBe(404);
+ const html = await response?.text();
+ // Canonical message must contain the trailing period to match Next.js
+ // (see .nextjs-ref/packages/next/src/client/components/builtin/not-found.tsx).
+ expect(html).toContain("This page could not be found.");
+ // Status code is surfaced as the
.
+ expect(html).toContain("404");
+ // Old vinext default ("404 - Page not found") must NOT leak through.
+ expect(html).not.toContain("404 - Page not found");
+ });
+
+ it("prefers a user-defined root not-found.tsx over the default", async () => {
+ const { renderer } = createRenderer({ rootNotFoundModule: notFoundModule });
+ const request = new Request("https://example.com/missing");
+
+ const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
+ headers: null,
+ status: null,
+ });
+
+ expect(response?.status).toBe(404);
+ const html = await response?.text();
+ // The user-defined boundary wins.
+ expect(html).toContain('data-boundary="not-found"');
+ expect(html).toContain("Missing page");
+ // The default not-found body must NOT leak through.
+ expect(html).not.toContain("This page could not be found.");
+ });
+});
+
// Mirrors Next.js 16 experimental.globalNotFound behavior.
// Ported from Next.js: test/e2e/app-dir/global-not-found/{basic,both-present,not-present}.
// Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
@@ -581,8 +623,9 @@ describe("app fallback renderer with globalNotFoundModule", () => {
// Mirrors test/e2e/app-dir/global-not-found/not-present: when the user
// opted into experimental.globalNotFound but never created the file,
// route-miss 404s should still serve the default 404 response. With no
- // root notFoundModule either, the renderer returns null and the caller
- // falls back to the framework's 404 Response.
+ // user-defined root notFoundModule either, vinext renders its built-in
+ // default not-found component (parity with Next.js's packaged
+ // not-found.tsx — "This page could not be found." with trailing period).
const { renderer } = createRenderer({
globalNotFoundModule: null,
rootLayoutModules: [rootLayoutModule],
@@ -594,8 +637,9 @@ describe("app fallback renderer with globalNotFoundModule", () => {
status: null,
});
- // No boundary component to render -> renderer returns null.
- expect(response).toBeNull();
+ expect(response?.status).toBe(404);
+ const html = await response?.text();
+ expect(html).toContain("This page could not be found.");
});
it("does not use global-not-found for non-404 access fallbacks (403, 401)", async () => {
diff --git a/tests/pages-default-404.test.ts b/tests/pages-default-404.test.ts
new file mode 100644
index 000000000..c7f7925da
--- /dev/null
+++ b/tests/pages-default-404.test.ts
@@ -0,0 +1,40 @@
+/**
+ * Regression for #1454.
+ *
+ * The Pages Router default 404 response (used when no `pages/404.tsx` and no
+ * `pages/_error.tsx` is defined) must include the canonical Next.js body
+ * `"This page could not be found."` (with trailing period). Pre-fix vinext
+ * shipped a minimal `
404 - Page not found
` placeholder that broke
+ * deploy-suite parity against `test/e2e/getserversideprops/test/index.test.ts`
+ * and `test/e2e/basepath/error-pages.test.ts`.
+ */
+import { describe, expect, it } from "vitest";
+import {
+ buildDefaultPagesNotFoundResponse,
+ DEFAULT_PAGES_NOT_FOUND_HTML,
+} from "../packages/vinext/src/server/pages-default-404.js";
+
+describe("buildDefaultPagesNotFoundResponse", () => {
+ it("returns a 404 status with the canonical Next.js body", async () => {
+ const response = buildDefaultPagesNotFoundResponse();
+ expect(response.status).toBe(404);
+ const body = await response.text();
+ // The Next.js deploy suite asserts on this substring (with the trailing
+ // period — see test/e2e/basepath/error-pages.test.ts).
+ expect(body).toContain("This page could not be found.");
+ // The 404 status code is rendered in the heading.
+ expect(body).toContain("404");
+ // Old vinext placeholder body must NOT leak through.
+ expect(body).not.toContain("404 - Page not found");
+ });
+
+ it("uses Next.js-compatible content-type", () => {
+ const response = buildDefaultPagesNotFoundResponse();
+ expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
+ });
+
+ it("exposes the raw HTML body for callers that need it", () => {
+ expect(DEFAULT_PAGES_NOT_FOUND_HTML).toContain("This page could not be found.");
+ expect(DEFAULT_PAGES_NOT_FOUND_HTML).toContain("");
+ });
+});
diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts
index af4dc813b..b023b42b1 100644
--- a/tests/pages-page-data.test.ts
+++ b/tests/pages-page-data.test.ts
@@ -115,7 +115,7 @@ describe("pages page data", () => {
throw new Error("expected response result");
}
expect(result.response.status).toBe(404);
- await expect(result.response.text()).resolves.toContain("404 - Page not found");
+ await expect(result.response.text()).resolves.toContain("This page could not be found.");
});
it("short-circuits getServerSideProps responses after res.end()", async () => {