Repository: vercel/next.js
Pull request: [backport] feat: add typesafety with config.typedRoutes to redirect() and permanentRedirect() (#82860)


Changed files:
filename: packages/next/src/server/lib/router-utils/typegen.ts
patch: @@ -329,6 +329,8 @@ declare module 'next/navigation' {
   export * from 'next/dist/client/components/navigation.js'
 
   import type { NavigateOptions, AppRouterInstance as OriginalAppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js'
+  import type { RedirectType } from 'next/dist/client/components/redirect-error.js'
+  
   interface AppRouterInstance extends OriginalAppRouterInstance {
     /**
      * Navigate to the provided href.
@@ -347,6 +349,41 @@ declare module 'next/navigation' {
   }
 
   export function useRouter(): AppRouterInstance;
+  
+  /**
+   * This function allows you to redirect the user to another URL. It can be used in
+   * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
+   * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
+   * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
+   *
+   * - In a Server Component, this will insert a meta tag to redirect the user to the target page.
+   * - In a Route Handler or Server Action, it will serve a 307/303 to the caller.
+   * - In a Server Action, type defaults to 'push' and 'replace' elsewhere.
+   *
+   * Read more: [Next.js Docs: redirect](https://nextjs.org/docs/app/api-reference/functions/redirect)
+   */
+  export function redirect<RouteType>(
+    /** The URL to redirect to */
+    url: __next_route_internal_types__.RouteImpl<RouteType>,
+    type?: RedirectType
+  ): never;
+  
+  /**
+   * This function allows you to redirect the user to another URL. It can be used in
+   * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
+   * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
+   * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
+   *
+   * - In a Server Component, this will insert a meta tag to redirect the user to the target page.
+   * - In a Route Handler or Server Action, it will serve a 308/303 to the caller.
+   *
+   * Read more: [Next.js Docs: redirect](https://nextjs.org/docs/app/api-reference/functions/redirect)
+   */
+  export function permanentRedirect<RouteType>(
+    /** The URL to redirect to */
+    url: __next_route_internal_types__.RouteImpl<RouteType>,
+    type?: RedirectType
+  ): never;
 }
 
 declare module 'next/form' {

filename: test/e2e/app-dir/typed-routes/typed-links.test.ts
patch: @@ -90,10 +90,102 @@ export default function InvalidLinks() {
       )
 
       const { exitCode, cliOutput } = await next.build()
+      // clean up for future tests
+      await next.deleteFile('app/invalid-links.tsx')
+
       expect(exitCode).toBe(1)
       expect(cliOutput).toContain(
         `Type error: "/invalid-route" is not an existing route. If it is intentional, please type it explicitly with \`as Route\`.`
       )
     })
+
+    it('should pass type checking with valid redirect routes', async () => {
+      await next.stop()
+      await next.patchFile(
+        'app/valid-redirects.tsx',
+        `
+import { redirect, permanentRedirect } from 'next/navigation'
+
+export default function ValidRedirects() {
+  function handleRedirect() {
+    redirect('/dashboard')
+    permanentRedirect('/project/123')
+  }
+
+  return (
+    <button onClick={handleRedirect}>
+      Test Redirects
+    </button>
+  )
+}
+`
+      )
+
+      const { exitCode } = await next.build()
+      expect(exitCode).toBe(0)
+    })
+
+    it('should work with redirect Route type casting', async () => {
+      await next.stop()
+      await next.patchFile(
+        'app/redirect-casting.tsx',
+        `
+import type { Route } from 'next'
+import { redirect, permanentRedirect } from 'next/navigation'
+
+export default function RedirectCasting() {
+  function handleRedirect() {
+    const dynamicPath = '/dynamic-path'
+    redirect(dynamicPath as Route)
+    permanentRedirect(dynamicPath as Route)
+  }
+
+  return (
+    <button onClick={handleRedirect}>
+      Casted Redirects
+    </button>
+  )
+}
+`
+      )
+
+      const { exitCode } = await next.build()
+      expect(exitCode).toBe(0)
+    })
+
+    it('should fail type checking with invalid redirect routes', async () => {
+      await next.stop()
+      await next.patchFile(
+        'app/invalid-redirects.tsx',
+        `
+import { redirect, permanentRedirect } from 'next/navigation'
+
+export default function InvalidRedirects() {
+  function handleRedirect() {
+    redirect('/invalid-route')
+    permanentRedirect('/another-invalid-route')
+  }
+
+  return (
+    <button onClick={handleRedirect}>
+      Invalid Redirects
+    </button>
+  )
+}
+`
+      )
+
+      const { exitCode, cliOutput } = await next.build()
+      // clean up for future tests
+      await next.deleteFile('app/invalid-redirects.tsx')
+
+      expect(exitCode).toBe(1)
+      expect(cliOutput).toContain(
+        `Type error: Argument of type '"/invalid-route"' is not assignable to parameter of type 'RouteImpl<"/invalid-route">'.`
+      )
+      expect(cliOutput).toContain(
+        `Type error: Argument of type '"/another-invalid-route"' is not assignable to parameter of type 'RouteImpl<"/another-invalid-route">'.`
+      )
+    })
   }
 })

filename: test/e2e/app-dir/typed-routes/typed-routes.test.ts
patch: @@ -79,6 +79,8 @@ type InvalidRoute = RouteContext<'/api/users/invalid'>`
       )
 
       const { cliOutput } = await next.build()
+      // clean up for future tests
+      await next.deleteFile('app/type-testing.ts')
 
       expect(cliOutput).toContain(
         `Type '"/dasboard"' does not satisfy the constraint 'AppRoutes'.`

filename: test/integration/app-types/app-types.test.js
patch: @@ -70,6 +70,21 @@ describe('app type checking - production mode', () => {
     )
   })
 
+  it('should generate route types correctly and report redirect errors', async () => {
+    // Make sure all errors were reported and other redirect functions passed type checking
+    const errorLines = [
+      ...errors.matchAll(
+        /\.\/src\/app\/type-checks\/redirect\/page\.tsx:(\d+):/g
+      ),
+    ].map(([, line]) => +line)
+
+    const ST = 7
+    const ED = 11
+    expect(errorLines).toEqual(
+      Array.from({ length: ED - ST + 1 }, (_, i) => i + ST)
+    )
+  })
+
   // Type validation is not supported in Turbopack yet.
   if (!process.env.IS_TURBOPACK_TEST && !process.env.TURBOPACK_DEV) {
     it('should type check invalid entry exports', () => {

filename: test/integration/app-types/src/app/type-checks/redirect/page.tsx
patch: @@ -0,0 +1,27 @@
+import { redirect, permanentRedirect } from 'next/navigation'
+import type { Route } from 'next'
+
+export default function Page() {
+  function testRedirect() {
+    // Invalid routes - these should cause type errors:
+    redirect('/wrong-link')
+    redirect('/blog/a?1/b')
+    redirect(`/blog/${'a/b/c'}`)
+    permanentRedirect('/nonexistent-route')
+    permanentRedirect('/wrong/route')
+
+    // Correctly typed - these should pass:
+    redirect('/dashboard/another')
+    redirect('/about')
+    redirect('/redirect')
+    redirect(`/blog/${'a/b'}`)
+    redirect('https://vercel.com')
+    redirect('/invalid' as Route)
+    permanentRedirect('/dashboard/user')
+    permanentRedirect('/blog/a/b')
+    permanentRedirect(`/dashboard/${'123'}`)
+    permanentRedirect('/external' as Route)
+  }
+
+  return <div onClick={testRedirect} />
+}