diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js
new file mode 100644
index 00000000000000..9f7255965b751b
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/dynamic-page/page.js
@@ -0,0 +1 @@
+export { default } from '../../nodejs/dynamic-page/page'
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js
new file mode 100644
index 00000000000000..b19ff8efbdce8e
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/layout.js
@@ -0,0 +1,3 @@
+export { default } from '../nodejs/layout'
+
+export const runtime = 'edge'
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js
new file mode 100644
index 00000000000000..6a1b84751684f2
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/middleware/page.js
@@ -0,0 +1 @@
+export { default } from '../../nodejs/middleware/page'
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js
new file mode 100644
index 00000000000000..8426f52cb33cd3
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/route/route.js
@@ -0,0 +1,4 @@
+export { GET } from '../../nodejs/route/route'
+
+export const runtime = 'edge'
+export const dynamic = 'force-dynamic'
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js b/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js
new file mode 100644
index 00000000000000..2f493b57190a5a
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/edge/server-action/page.js
@@ -0,0 +1 @@
+export { default } from '../../nodejs/server-action/page'
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/layout.js
new file mode 100644
index 00000000000000..a55016aeb623b0
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/layout.js
@@ -0,0 +1,10 @@
+export default function AppLayout({ children }) {
+ return (
+
+
+ after
+
+ {children}
+
+ )
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js
new file mode 100644
index 00000000000000..7e230bc7697edb
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/dynamic-page/page.js
@@ -0,0 +1,11 @@
+import { unstable_after as after } from 'next/server'
+import { revalidateTimestampPage } from '../../timestamp/revalidate'
+import { pathPrefix } from '../../path-prefix'
+
+export default function Page() {
+ after(async () => {
+ await revalidateTimestampPage(pathPrefix + `/dynamic-page`)
+ })
+
+ return Page with after()
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js
new file mode 100644
index 00000000000000..44f23cfc048f60
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/layout.js
@@ -0,0 +1,5 @@
+export const runtime = 'nodejs'
+
+export default function Layout({ children }) {
+ return <>{children}>
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js
new file mode 100644
index 00000000000000..6321fa7d959879
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/middleware/page.js
@@ -0,0 +1,3 @@
+export default function Page() {
+ return Redirect
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js
new file mode 100644
index 00000000000000..439bb58e455f35
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/route/route.js
@@ -0,0 +1,15 @@
+import { unstable_after as after } from 'next/server'
+import { revalidateTimestampPage } from '../../timestamp/revalidate'
+import { pathPrefix } from '../../path-prefix'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+export async function GET() {
+ const data = { message: 'Hello, world!' }
+ after(async () => {
+ await revalidateTimestampPage(pathPrefix + `/route`)
+ })
+
+ return Response.json({ data })
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js
new file mode 100644
index 00000000000000..d46488e9292d91
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/nodejs/server-action/page.js
@@ -0,0 +1,20 @@
+import { unstable_after as after } from 'next/server'
+import { revalidateTimestampPage } from '../../timestamp/revalidate'
+import { pathPrefix } from '../../path-prefix'
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js b/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js
new file mode 100644
index 00000000000000..dfb4e0c7f8ff12
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/path-prefix.js
@@ -0,0 +1 @@
+export const pathPrefix = '/' + process.env.NEXT_RUNTIME
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js
new file mode 100644
index 00000000000000..c15fc419f15aa4
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/key/[key]/page.js
@@ -0,0 +1,16 @@
+export const dynamic = 'error'
+export const revalidate = 3600 // arbitrarily long, just so that it doesn't happen during a test run
+export const dynamicParams = true
+
+export async function generateStaticParams() {
+ return []
+}
+
+export default function Page({ params }) {
+ const data = {
+ key: params.key,
+ timestamp: Date.now(),
+ }
+ console.log('/timestamp/key/[key] rendered', data)
+ return {JSON.stringify(data)}
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js
new file mode 100644
index 00000000000000..b7142562cf4d5e
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/revalidate.js
@@ -0,0 +1,33 @@
+import { revalidatePath } from 'next/cache'
+
+export async function revalidateTimestampPage(/** @type {string} */ key) {
+ const path = `/timestamp/key/${encodeURIComponent(key)}`
+
+ const sleepDuration = getSleepDuration()
+ if (sleepDuration > 0) {
+ console.log(`revalidateTimestampPage :: sleeping for ${sleepDuration} ms`)
+ await sleep(sleepDuration)
+ }
+
+ console.log('revalidateTimestampPage :: revalidating', path)
+ revalidatePath(path)
+}
+
+const WAIT_BEFORE_REVALIDATING_DEFAULT = 5000
+
+function getSleepDuration() {
+ const raw = process.env.WAIT_BEFORE_REVALIDATING
+ if (!raw) return WAIT_BEFORE_REVALIDATING_DEFAULT
+
+ const parsed = Number.parseInt(raw)
+ if (Number.isNaN(parsed)) {
+ throw new Error(
+ `WAIT_BEFORE_REVALIDATING must be a valid number, got: ${JSON.stringify(raw)}`
+ )
+ }
+ return parsed
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js
new file mode 100644
index 00000000000000..18eb6a554db42a
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/app/timestamp/trigger-revalidate/route.js
@@ -0,0 +1,14 @@
+import { revalidateTimestampPage } from '../revalidate'
+
+export async function POST(/** @type {Request} */ request) {
+ // we can't call revalidatePath from middleware, so we need to do it from here instead
+ const path = new URL(request.url).searchParams.get('path')
+ if (!path) {
+ return Response.json(
+ { message: 'Missing "path" search param' },
+ { status: 400 }
+ )
+ }
+ await revalidateTimestampPage(path)
+ return Response.json({})
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/index.test.ts b/test/e2e/app-dir/next-after-app-deploy/index.test.ts
new file mode 100644
index 00000000000000..445ce4cf874b19
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/index.test.ts
@@ -0,0 +1,116 @@
+/* eslint-env jest */
+import { nextTestSetup, isNextDev, isNextDeploy } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+
+const runtimes = ['nodejs', 'edge']
+
+const WAIT_BEFORE_REVALIDATING = isNextDeploy ? 10_000 : 5_000
+
+// If we want to verify that `unstable_after()` ran its callback,
+// we need it to perform some kind of side effect (because it can't affect the response).
+// In other tests, we often use logs for this, but we don't have access to those in deploy tests.
+// So instead this test relies on calling `revalidatePath` inside `unstable_after`
+// to revalidate an ISR page '/timestamp/key/[key]', and then checking if the timestamp changed --
+// if it did, we successfully ran the callback (and performed a side effect).
+
+// This test relies on revalidating a static page, so it can't work in dev mode.
+const _describe = isNextDev ? describe : describe.skip
+
+_describe.each(runtimes)('unstable_after() in %s runtime', (runtimeValue) => {
+ const { next, skipped } = nextTestSetup({
+ files: __dirname,
+ env: { WAIT_BEFORE_REVALIDATING: WAIT_BEFORE_REVALIDATING + '' },
+ })
+ const retryDuration = WAIT_BEFORE_REVALIDATING * 2
+
+ if (skipped) return
+ const pathPrefix = '/' + runtimeValue
+
+ type PageInfo = {
+ key: string
+ timestamp: number
+ }
+
+ const getTimestampPageData = async (path: string): Promise => {
+ const fullPath = `/timestamp/key/${encodeURIComponent(path)}`
+ const response = await next.render$(fullPath)
+ const dataStr = response('#page-info').text()
+ if (!dataStr) {
+ throw new Error(`No page data found for '${fullPath}'`)
+ }
+ return JSON.parse(dataStr) as PageInfo
+ }
+
+ it('triggers revalidate from a page', async () => {
+ const path = pathPrefix + '/dynamic-page'
+ const dataBefore = await getTimestampPageData(path)
+ expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static
+
+ await next.render(path) // trigger revalidate
+
+ await retry(
+ async () => {
+ const dataAfter = await getTimestampPageData(path)
+ expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp)
+ },
+ retryDuration,
+ 1000,
+ 'check if timestamp page updated'
+ )
+ })
+
+ it('triggers revalidate from a server action', async () => {
+ const path = pathPrefix + '/server-action'
+ const dataBefore = await getTimestampPageData(path)
+ expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static
+
+ const session = await next.browser(path)
+ await session.elementByCss('button[type="submit"]').click() // trigger revalidate
+
+ await retry(
+ async () => {
+ const dataAfter = await getTimestampPageData(path)
+ expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp)
+ },
+ retryDuration,
+ 1000,
+ 'check if timestamp page updated'
+ )
+ })
+
+ it('triggers revalidate from a route handler', async () => {
+ const path = pathPrefix + '/route'
+ const dataBefore = await getTimestampPageData(path)
+ expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static
+
+ await next.fetch(path).then((res) => res.text()) // trigger revalidate
+
+ await retry(
+ async () => {
+ const dataAfter = await getTimestampPageData(path)
+ expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp)
+ },
+ retryDuration,
+ 1000,
+ 'check if timestamp page updated'
+ )
+ })
+
+ it('triggers revalidate from middleware', async () => {
+ const path = pathPrefix + '/middleware'
+ const dataBefore = await getTimestampPageData(path)
+ expect(dataBefore).toEqual(await getTimestampPageData(path)) // sanity check that it's static
+
+ await next.render(path) // trigger revalidate
+
+ await retry(
+ async () => {
+ const dataAfter = await getTimestampPageData(path)
+ expect(dataAfter.timestamp).toBeGreaterThan(dataBefore.timestamp)
+ },
+ retryDuration,
+ 1000,
+ 'check if timestamp page updated'
+ )
+ })
+})
diff --git a/test/e2e/app-dir/next-after-app-deploy/middleware.js b/test/e2e/app-dir/next-after-app-deploy/middleware.js
new file mode 100644
index 00000000000000..b3ca2ecbd3def0
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/middleware.js
@@ -0,0 +1,30 @@
+import { unstable_after as after } from 'next/server'
+
+export function middleware(
+ /** @type {import ('next/server').NextRequest} */ request
+) {
+ const url = new URL(request.url)
+
+ const match = url.pathname.match(/^(?\/[^/]+?)\/middleware/)
+ if (match) {
+ const pathPrefix = match.groups.prefix
+ after(async () => {
+ // we can't call revalidatePath from middleware, so we need to do it via an endpoint instead
+ const pathToRevalidate = pathPrefix + `/middleware`
+
+ const postUrl = new URL('/timestamp/trigger-revalidate', url.href)
+ postUrl.searchParams.append('path', pathToRevalidate)
+
+ const response = await fetch(postUrl, { method: 'POST' })
+ if (!response.ok) {
+ throw new Error(
+ `Failed to revalidate path '${pathToRevalidate}' (status: ${response.status})`
+ )
+ }
+ })
+ }
+}
+
+export const config = {
+ matcher: ['/:prefix/middleware'],
+}
diff --git a/test/e2e/app-dir/next-after-app-deploy/next.config.js b/test/e2e/app-dir/next-after-app-deploy/next.config.js
new file mode 100644
index 00000000000000..992cdf8821d4e2
--- /dev/null
+++ b/test/e2e/app-dir/next-after-app-deploy/next.config.js
@@ -0,0 +1,8 @@
+/** @type {import('next').NextConfig} */
+module.exports = {
+ experimental: {
+ after: true,
+ // DO NOT turn this on, it disables the incremental cache! (see `disableForTestmode`)
+ // testProxy: true,
+ },
+}