From a630fb0806328987cd19681b290e4a6f34274dfa Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:20:22 +1000 Subject: [PATCH 1/4] fix(app-router): reject middleware control responses in route handlers App Route handlers currently treat NextResponse.next() and NextResponse.rewrite() as ordinary 200 responses. That diverges from Next.js, where those helpers are middleware control-flow signals and are rejected after a route handler returns. The route-handler execution path now validates returned responses before cache policy or response finalization runs. Focused unit and integration tests cover the invalid next and rewrite helper responses. --- .../src/server/app-route-handler-execution.ts | 2 + .../src/server/app-route-handler-response.ts | 17 ++++ tests/app-route-handler-execution.test.ts | 88 +++++++++++++++++++ tests/app-router.test.ts | 14 +++ .../api/invalid-next-response-next/route.ts | 5 ++ .../invalid-next-response-rewrite/route.ts | 5 ++ 6 files changed, 131 insertions(+) create mode 100644 tests/fixtures/app-basic/app/api/invalid-next-response-next/route.ts create mode 100644 tests/fixtures/app-basic/app/api/invalid-next-response-rewrite/route.ts diff --git a/packages/vinext/src/server/app-route-handler-execution.ts b/packages/vinext/src/server/app-route-handler-execution.ts index 25ea66cce..c4561093a 100644 --- a/packages/vinext/src/server/app-route-handler-execution.ts +++ b/packages/vinext/src/server/app-route-handler-execution.ts @@ -12,6 +12,7 @@ import { import { applyRouteHandlerMiddlewareContext, applyRouteHandlerRevalidateHeader, + assertSupportedAppRouteHandlerResponse, buildAppRouteCacheValue, finalizeRouteHandlerResponse, markRouteHandlerCacheMiss, @@ -109,6 +110,7 @@ export async function executeAppRouteHandler( try { const { dynamicUsedInHandler, response } = await runAppRouteHandler(options); + assertSupportedAppRouteHandlerResponse(response); const handlerSetCacheControl = response.headers.has("cache-control"); if (dynamicUsedInHandler) { diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 50d8b3b14..51e1ae941 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -22,6 +22,11 @@ type FinalizeRouteHandlerResponseOptions = { // See .nextjs-ref/packages/next/src/server/lib/cache-control.ts. const NEVER_CACHE_CONTROL = "private, no-cache, no-store, max-age=0, must-revalidate"; +const APP_ROUTE_REWRITE_ERROR = + "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue."; +const APP_ROUTE_NEXT_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"; + function buildRouteHandlerCacheControl( cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"], revalidateSeconds: number, @@ -59,6 +64,18 @@ export function applyRouteHandlerMiddlewareContext( }); } +export function assertSupportedAppRouteHandlerResponse(response: Response): void { + // NextResponse.next() and rewrite() are middleware control-flow signals. + // Once an App Route handler has returned, Next.js rejects those responses. + if (response.headers.has("x-middleware-rewrite")) { + throw new Error(APP_ROUTE_REWRITE_ERROR); + } + + if (response.headers.get("x-middleware-next") === "1") { + throw new Error(APP_ROUTE_NEXT_ERROR); + } +} + export function buildRouteHandlerCachedResponse( cachedValue: CachedRouteValue, options: BuildRouteHandlerCachedResponseOptions, diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 0b832aa37..0fa20daf7 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -297,4 +297,92 @@ describe("app route handler execution helpers", () => { errorSpy.mockRestore(); }); + + it("rejects middleware control responses returned from route handlers", async () => { + // The NextResponse.next() case is ported from Next.js: + // test/e2e/app-dir/app-routes/app-custom-routes.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts + // The NextResponse.rewrite() case mirrors the adjacent App Route module validation. + const cases = [ + { + headerName: "x-middleware-next", + headerValue: "1", + message: + "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", + }, + { + headerName: "x-middleware-rewrite", + headerValue: "https://example.com/rewritten", + message: + "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.", + }, + ]; + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + for (const testCase of cases) { + const dynamicUsage = createDynamicUsageState(); + const reportedErrors: Error[] = []; + let wroteCache = false; + let didClearRequestContext = false; + + const response = await executeAppRouteHandler({ + buildPageCacheTags(pathname, extraTags) { + return [pathname, ...extraTags]; + }, + cleanPathname: "/api/middleware-control", + clearRequestContext() { + didClearRequestContext = true; + }, + consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + executionContext: null, + getAndClearPendingCookies() { + return []; + }, + getCollectedFetchTags() { + return []; + }, + getDraftModeCookieHeader() { + return null; + }, + handler: { dynamic: "auto" }, + handlerFn() { + return new Response("should not be sent", { + headers: { [testCase.headerName]: testCase.headerValue }, + }); + }, + isAutoHead: false, + isProduction: true, + isrRouteKey(pathname) { + return "route:" + pathname; + }, + async isrSet() { + wroteCache = true; + }, + markDynamicUsage: dynamicUsage.markDynamicUsage, + method: "GET", + middlewareContext: { headers: null, status: null }, + params: {}, + reportRequestError(error) { + reportedErrors.push(error); + }, + request: new Request("https://example.com/api/middleware-control"), + revalidateSeconds: 60, + routePattern: "/api/middleware-control", + setHeadersAccessPhase() { + return "render"; + }, + }); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe(""); + expect(reportedErrors.map((error) => error.message)).toEqual([testCase.message]); + expect(wroteCache).toBe(false); + expect(didClearRequestContext).toBe(true); + } + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 489db4850..7b23641d1 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -631,6 +631,20 @@ describe("App Router integration", () => { expect(body).toBe(""); }); + it("rejects middleware control responses returned from route handlers", async () => { + // The NextResponse.next() case is ported from Next.js: + // test/e2e/app-dir/app-routes/app-custom-routes.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts + // The NextResponse.rewrite() case mirrors the adjacent App Route module validation. + const nextRes = await fetch(`${baseUrl}/api/invalid-next-response-next`); + expect(nextRes.status).toBe(500); + expect(await nextRes.text()).toBe(""); + + const rewriteRes = await fetch(`${baseUrl}/api/invalid-next-response-rewrite`); + expect(rewriteRes.status).toBe(500); + expect(await rewriteRes.text()).toBe(""); + }); + it("catches redirect() thrown in route handlers", async () => { const res = await fetch(`${baseUrl}/api/redirect-route`, { redirect: "manual" }); expect(res.status).toBe(307); diff --git a/tests/fixtures/app-basic/app/api/invalid-next-response-next/route.ts b/tests/fixtures/app-basic/app/api/invalid-next-response-next/route.ts new file mode 100644 index 000000000..0aea69d69 --- /dev/null +++ b/tests/fixtures/app-basic/app/api/invalid-next-response-next/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET() { + return NextResponse.next(); +} diff --git a/tests/fixtures/app-basic/app/api/invalid-next-response-rewrite/route.ts b/tests/fixtures/app-basic/app/api/invalid-next-response-rewrite/route.ts new file mode 100644 index 000000000..84bbe808f --- /dev/null +++ b/tests/fixtures/app-basic/app/api/invalid-next-response-rewrite/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET(request: Request) { + return NextResponse.rewrite(new URL("/api/hello", request.url)); +} From 0e03861103e9aaa74d2878c4ec322f4387a685e2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:27:46 +1000 Subject: [PATCH 2/4] Update tests/app-route-handler-execution.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/app-route-handler-execution.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index 0fa20daf7..cec84cc71 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -308,13 +308,13 @@ describe("app route handler execution helpers", () => { headerName: "x-middleware-next", headerValue: "1", message: - "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", + "NextResponse.next() was used in an app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler", }, { headerName: "x-middleware-rewrite", headerValue: "https://example.com/rewritten", message: - "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.", + "NextResponse.rewrite() was used in an app route handler, this is not currently supported. Please remove the invocation to continue.", }, ]; From 4f41858d7b933460a36ec66b4a81c3bffcad9531 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:27:56 +1000 Subject: [PATCH 3/4] Update packages/vinext/src/server/app-route-handler-response.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/vinext/src/server/app-route-handler-response.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 51e1ae941..c3ce6919d 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -23,9 +23,9 @@ type FinalizeRouteHandlerResponseOptions = { const NEVER_CACHE_CONTROL = "private, no-cache, no-store, max-age=0, must-revalidate"; const APP_ROUTE_REWRITE_ERROR = - "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue."; + "NextResponse.rewrite() was used in an app route handler, this is not currently supported. Please remove the invocation to continue."; const APP_ROUTE_NEXT_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"; + "NextResponse.next() was used in an app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler"; function buildRouteHandlerCacheControl( cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"], From 19ef0e2b237a3b38a2fdde4b805ce3358ee5da15 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:58:17 +1000 Subject: [PATCH 4/4] fix(app-router): validate route handler ISR regeneration responses Stale App Route handler regeneration called runAppRouteHandler directly and could serialize a middleware control response into the ISR cache. That bypassed the validation used by the live route-handler execution path. Run the same response assertion before background regeneration writes a new APP_ROUTE cache entry, restore exact Next.js error wording for parity, and cover the x-middleware-next boundary semantics. --- .../src/server/app-route-handler-cache.ts | 2 + .../src/server/app-route-handler-response.ts | 4 +- tests/app-route-handler-cache.test.ts | 61 +++++++++++++++++++ tests/app-route-handler-execution.test.ts | 4 +- tests/app-route-handler-response.test.ts | 25 ++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-route-handler-cache.ts b/packages/vinext/src/server/app-route-handler-cache.ts index e817e8806..b1d036dcc 100644 --- a/packages/vinext/src/server/app-route-handler-cache.ts +++ b/packages/vinext/src/server/app-route-handler-cache.ts @@ -3,6 +3,7 @@ import type { ISRCacheEntry } from "./isr-cache.js"; import type { RouteHandlerMiddlewareContext } from "./app-route-handler-response.js"; import { applyRouteHandlerMiddlewareContext, + assertSupportedAppRouteHandlerResponse, buildAppRouteCacheValue, buildRouteHandlerCachedResponse, } from "./app-route-handler-response.js"; @@ -102,6 +103,7 @@ export async function readAppRouteHandlerCacheResponse( }); options.setNavigationContext(null); + assertSupportedAppRouteHandlerResponse(response); if (dynamicUsedInHandler) { markKnownDynamicAppRoute(options.routePattern); diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index c3ce6919d..51e1ae941 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -23,9 +23,9 @@ type FinalizeRouteHandlerResponseOptions = { const NEVER_CACHE_CONTROL = "private, no-cache, no-store, max-age=0, must-revalidate"; const APP_ROUTE_REWRITE_ERROR = - "NextResponse.rewrite() was used in an app route handler, this is not currently supported. Please remove the invocation to continue."; + "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue."; const APP_ROUTE_NEXT_ERROR = - "NextResponse.next() was used in an app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler"; + "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"; function buildRouteHandlerCacheControl( cacheState: BuildRouteHandlerCachedResponseOptions["cacheState"], diff --git a/tests/app-route-handler-cache.test.ts b/tests/app-route-handler-cache.test.ts index d7b3357ae..ef1a9e37a 100644 --- a/tests/app-route-handler-cache.test.ts +++ b/tests/app-route-handler-cache.test.ts @@ -231,6 +231,67 @@ describe("app route handler cache helpers", () => { expect(isKnownDynamicAppRoute(routePattern)).toBe(true); }); + it("rejects invalid route handler responses during background regeneration", async () => { + const dynamicUsage = createDynamicUsageState(); + const scheduledRegens: Array<() => Promise> = []; + let wroteCache = false; + + const response = await readAppRouteHandlerCacheResponse({ + buildPageCacheTags(pathname, extraTags) { + return [pathname, ...extraTags]; + }, + cleanPathname: "/api/stale-invalid", + clearRequestContext() {}, + consumeDynamicUsage: dynamicUsage.consumeDynamicUsage, + getCollectedFetchTags() { + return []; + }, + handlerFn() { + return new Response("should not be cached", { + headers: { "x-middleware-next": "1" }, + }); + }, + isAutoHead: false, + async isrGet() { + return buildISRCacheEntry(buildCachedRouteValue("from-stale"), true); + }, + isrRouteKey(pathname) { + return "route:" + pathname; + }, + async isrSet() { + wroteCache = true; + }, + markDynamicUsage: dynamicUsage.markDynamicUsage, + middlewareContext: { headers: null, status: null }, + params: {}, + requestUrl: "https://example.com/api/stale-invalid", + revalidateSearchParams: new URLSearchParams(), + revalidateSeconds: 60, + routePattern: "/api/stale-invalid", + async runInRevalidationContext(renderFn) { + await renderFn(); + }, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRegens.push(renderFn); + }, + setNavigationContext() {}, + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + await expect(response?.text()).resolves.toBe("from-stale"); + expect(scheduledRegens).toHaveLength(1); + + const scheduledRegenRun = scheduledRegens[0]; + if (!scheduledRegenRun) { + throw new Error("Expected scheduled route regeneration"); + } + + await expect(scheduledRegenRun()).rejects.toThrow( + "NextResponse.next() was used in a app route handler", + ); + expect(wroteCache).toBe(false); + }); + it("falls through on cache read errors", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/app-route-handler-execution.test.ts b/tests/app-route-handler-execution.test.ts index cec84cc71..0fa20daf7 100644 --- a/tests/app-route-handler-execution.test.ts +++ b/tests/app-route-handler-execution.test.ts @@ -308,13 +308,13 @@ describe("app route handler execution helpers", () => { headerName: "x-middleware-next", headerValue: "1", message: - "NextResponse.next() was used in an app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler", + "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", }, { headerName: "x-middleware-rewrite", headerValue: "https://example.com/rewritten", message: - "NextResponse.rewrite() was used in an app route handler, this is not currently supported. Please remove the invocation to continue.", + "NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.", }, ]; diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts index 811a3019a..cf1c2df14 100644 --- a/tests/app-route-handler-response.test.ts +++ b/tests/app-route-handler-response.test.ts @@ -3,6 +3,7 @@ import type { CachedRouteValue } from "../packages/vinext/src/shims/cache.js"; import { applyRouteHandlerMiddlewareContext, applyRouteHandlerRevalidateHeader, + assertSupportedAppRouteHandlerResponse, buildAppRouteCacheValue, buildRouteHandlerCachedResponse, finalizeRouteHandlerResponse, @@ -219,6 +220,30 @@ describe("app route handler response helpers", () => { expect(response.headers.get("x-vinext-cache")).toBe("MISS"); }); + it("only rejects the active x-middleware-next control signal", () => { + expect(() => + assertSupportedAppRouteHandlerResponse( + new Response(null, { + headers: { "x-middleware-next": "0" }, + }), + ), + ).not.toThrow(); + expect(() => + assertSupportedAppRouteHandlerResponse( + new Response(null, { + headers: { "x-middleware-next": "true" }, + }), + ), + ).not.toThrow(); + expect(() => + assertSupportedAppRouteHandlerResponse( + new Response(null, { + headers: { "x-middleware-next": "1" }, + }), + ), + ).toThrow("NextResponse.next() was used in a app route handler"); + }); + it("emits a no-store Cache-Control for revalidate = 0 route handlers", () => { // A handler exporting `revalidate = 0` opts out of caching entirely. // The Cache-Control must tell browsers and CDNs never to store the