permanentRedirect()

Sends the user to a new URL with a 308 status for content that has permanently moved.

Signature
permanentRedirect(path: string, type?: 'replace' | 'push'): never

Parameters

ParameterTypeDescription
$pathstringThe URL to send the user to. Can be a relative path or an absolute path.
$typeoptional'replace' | 'push'= 'replace' ('push' in Server Actions)How the browser history stack is updated. Defaults to 'replace' everywhere except Server Actions, where it defaults to 'push'. Has no effect in Server Components. The RedirectType object exports the same two values.

Return value

neverDoes not return a value. It throws a NEXT_REDIRECT control-flow error and terminates rendering of the route segment it was thrown in.

Errors

Always throws a NEXT_REDIRECT error by design; wrapping the call in try/catch suppresses the redirect.

permanentRedirect() is redirect() with a different status code: 308 instead of 307. Both preserve the request method, so a POST stays a POST. The difference is the message to clients and crawlers — 308 says the resource has moved for good, and search engines are expected to transfer ranking to the new URL and stop requesting the old one.

Use it for structural URL changes: a renamed slug, a product folded into another, a section moved under a new prefix. Use plain redirect() for anything conditional on the current request — an auth check, a feature flag, a post-mutation destination — because those decisions can change on the next request and a 308 tells the client never to ask again.

The rest matches redirect()

It throws NEXT_REDIRECT, so nothing after the call runs and return is unnecessary thanks to the never return type. A try/catch around it cancels the redirect. A progressive-enhancement form submission to a Server Function still serves 303, and with JavaScript available a Server Action performs a client-side navigation instead.

If the URL is dead rather than moved, call notFound().

Example

Example · typescript
import { permanentRedirect } from 'next/navigation'
import { getPost, getRenamedSlug } from '@/lib/posts'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    const currentSlug = await getRenamedSlug(slug)

    if (currentSlug) {
      permanentRedirect(`/blog/${currentSlug}`)
    }
  }

  return <article>{post.body}</article>
}

When to use it

  • Point an old article slug at its renamed replacement after a content migration
  • Move a whole section from /docs/* to /guides/* while keeping inbound links alive
  • Collapse a duplicate product page onto the canonical listing
  • Retire a marketing landing page in favour of its successor

Frequently asked questions

What is the difference between permanentRedirect and redirect in Next.js?

permanentRedirect issues a 308 and redirect issues a 307; both preserve the HTTP method. Use 308 only when the URL has genuinely moved for good, because clients and crawlers may cache it and never request the original path again.

Why is my permanentRedirect returning 303 instead of 308?

Because it was called during a progressive-enhancement Server Action form submission, which serves 303 so the browser follows the redirect with a GET. When JavaScript is available, the Server Action does a client-side navigation and there is no HTTP redirect status at all.

How do I undo a permanent redirect I deployed by mistake?

Remove the call and deploy, but browsers that already cached the 308 will keep following it until their cache expires or the user clears it. There is no way to recall it from the client side, which is why conditional redirects should use redirect() and its 307.

More examples

Falling back to a 404 when nothing replaced the URL

Two different outcomes for two different situations. A slug that maps to a replacement gets a 308 so crawlers move their index entry across; a slug that maps to nothing gets a 404 and renders not-found.js. Both calls throw, so neither needs a return and the code below stays unreachable.

Example · typescript
// app/blog/[slug]/page.tsx
import { notFound, permanentRedirect } from 'next/navigation'
import { getPost, getRenamedSlug } from '@/lib/posts'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    const currentSlug = await getRenamedSlug(slug)

    if (currentSlug) {
      permanentRedirect(`/blog/${currentSlug}`) // 308
    }

    notFound() // 404
  }

  return <article>{post.body}</article>
}

Moving a whole route prefix in a layout

A layout runs for every nested segment, so one check covers an entire subtree of moved URLs. `headers()` is async in Next.js 15+ and must be awaited. Note that this runs during rendering — for a mapping this mechanical, a redirects entry in next.config.js or a proxy handles it before rendering starts and is cheaper.

Example · typescript
// app/docs/layout.tsx
import { permanentRedirect } from 'next/navigation'
import { headers } from 'next/headers'

export default async function DocsLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const headerList = await headers()
  const pathname = headerList.get('x-pathname') ?? ''

  if (pathname.startsWith('/docs/legacy/')) {
    permanentRedirect(pathname.replace('/docs/legacy/', '/guides/'))
  }

  return <main>{children}</main>
}

Permanently redirecting from a Route Handler

The handler issues a 308 rather than a 307, so a client that caches redirects will call /api/v2 directly on subsequent requests. Because 308 preserves the method, a POST to the old endpoint arrives at the new one still as a POST with its body intact.

Example · typescript
// app/api/v1/[...path]/route.ts
import { permanentRedirect } from 'next/navigation'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params

  // API v1 was retired; every route has a v2 equivalent.
  permanentRedirect(`/api/v2/${path.join('/')}`)
}

Discussion

  • Be the first to comment on this lesson.