Next.js Redirect 307 vs 308 vs 301 for SEO: What I Tested
In the Next.js App Router, redirect() sends a 307, which Google treats as temporary. Use permanentRedirect() (308) or a redirect in next.config with permanent: true when a page has moved for good. And if the route streams, through loading.tsx or a Suspense boundary, neither function sends a redirect status at all.
I tested ten redirect setups on Next.js 16.4.4 with next build and next start, and read the status line and Location header with curl, using the Googlebot user agent.
The results
Setup Status Location
next.config redirects, permanent: true 308 /target
next.config redirects, permanent: false 307 /target
next.config redirects, statusCode: 301 301 /target
redirect() in a page 307 /target
permanentRedirect() in a page 308 /target
permanentRedirect() + loading.tsx 200 none
permanentRedirect() inside a <Suspense> child 200 none
NextResponse.redirect(url) in proxy.ts 307 /target
NextResponse.redirect(url, 308) in proxy.ts 308 /target
NextResponse.redirect(url, 301) in proxy.ts 301 /targetredirect() is temporary
redirect() is the function most tutorials show, and it sent 307 Temporary Redirect. Google lists 307 with 302 as temporary: Googlebot follows it, but it doesn't take it as a sign that the target should be the canonical URL. The old URL can stay in search results.
For a page that has moved for good, such as a renamed slug or a merged post, use permanentRedirect() instead:
import { permanentRedirect } from "next/navigation";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const moved = await findNewSlug(slug); // your own lookup
if (moved) permanentRedirect(`/blog/${moved}`);
// ...
}NextResponse.redirect() is temporary too
If you redirect in proxy.ts (called middleware.ts before Next.js 16), NextResponse.redirect() without a status sent 307. Pass the status as the second argument for a permanent move:
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === "/old-post") {
return NextResponse.redirect(new URL("/writing/new-post", request.url), 308);
}
return NextResponse.next();
}
export const config = { matcher: ["/old-post"] };proxy.ts runs before the page renders, so loading.tsx doesn't affect it. Watch the matcher, though: my first try used "/p-:path*", which didn't match /p-default, and the request fell through to a 404.
308 or 301 makes no difference to Google
Next.js uses 308 for permanent redirects. Google's redirects page lists 301 and 308 together as permanent, so you don't need to force 301 for SEO. If an old client or tool needs a 301, next.config redirects accept statusCode: 301, which is what my test returned.
In a streamed route the redirect is a 200
With loading.tsx next to the page, the same permanentRedirect() returned 200 OK with no Location header. Next.js had already sent the loading fallback, so the status was fixed. This is the HTML it sent, with the scripts removed:
<head>...<title>Test</title></head>
<body>
<template id="B:0"></template><p>Loading…</p>
<div hidden id="S:0">...</div>
<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/target"/>
</body>The redirect is a meta refresh at the end of the body. Google counts an instant meta refresh as a permanent redirect, but tells you to place it in the <head> or send it as an HTTP header. Tools that read only the status code see a 200 page that says Loading.
The fix: redirect before anything streams
For moves you know ahead of time, put them in next.config. They run before the page renders, so the status is always right:
// next.config.ts
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: "/old-post", destination: "/writing/new-post", permanent: true },
];
},
};For redirects that need a database lookup, call permanentRedirect() in the segment's layout.tsx instead of the page. loading.tsx wraps the page, not the layout of the same segment, so the layout runs before anything streams. With loading.tsx still in place, the same lookup moved into the layout returned 308 with Location: /target.
// app/blog/[slug]/layout.tsx
import { permanentRedirect } from "next/navigation";
export default async function Layout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const moved = await findNewSlug((await params).slug);
if (moved) permanentRedirect(`/blog/${moved}`);
return children;
}Wrap the lookup in React's cache() if the page needs the same data, so it runs once per request. notFound() has the same streaming problem and the same fix, covered in the post linked below.
Check your own redirects
curl -sI https://your-site.com/old-url | grep -iE "^HTTP|^location"You want a 308 or 301 and a Location line. A 307 means the code uses redirect() where permanentRedirect() belongs, or NextResponse.redirect() without a status. A 200 with no Location means the redirect runs after streaming has started.
Related
- Next.js notFound() returning 200 instead of 404? Tested fix
- Next.js canonical URL: 4 traps I tested and the fix
- Next.js metadata checker
- Next.js development