
Next.js Handling redirects
"Just redirect the user" sounds like it should have one obvious answer, and in Next.js it deliberately doesn't — because "redirect after this form submits" and "redirect this URL structure permanently" and "redirect based on whether this cookie exists" are three genuinely different problems with three genuinely different right tools. Reaching for the wrong one usually still works, in the sense that the user ends up somewhere else, but it tends to cost you the wrong status code, an unnecessary render, or logic duplicated somewhere it shouldn't be.
This article goes through all five redirect mechanisms Next.js offers, when each one is actually the right call, and — since this is a genuinely common production need — how to manage a redirect list too large for ordinary configuration.
The five options, at a glance
| API | Purpose | Where it runs | Status code |
|---|---|---|---|
redirect | Redirect after a mutation or event | Server Components, Server Functions, Route Handlers | 307 (or 303 for a Server Action) |
permanentRedirect | Redirect after something changes a canonical URL | Same as above | 308 |
useRouter().push | Client-side navigation from an event handler | Client Component event handlers | N/A |
redirects in next.config.js | Redirect a known, incoming path | Config file | 307 or 308, your choice |
NextResponse.redirect in Proxy | Redirect based on a runtime condition | proxy.js | Any |
The table alone is worth internalizing before the details, because the actual decision usually comes down to one question: do you know the redirect ahead of time (config), does it depend on a request-time condition like auth (Proxy), or is it a consequence of something your own code just did (the redirect/permanentRedirect functions)?
redirect: for after a mutation or event
This is the one you reach for constantly in ordinary application code — the pattern of "do something, then send the user somewhere as a result":
// app/actions.ts
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createPost(id: string) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidatePath("/posts");
redirect(`/post/${id}`);
}
Two behavioral details worth knowing precisely rather than treating as trivia: inside a Server Action specifically, redirect performs a client-side navigation when JavaScript is available, but falls back to a 303 (See Other) status for a plain, no-JS form submission — everywhere else it uses, redirect sends a 307 (Temporary Redirect). And redirect works by throwing — which is precisely why it must be called outside a try block, not inside one, or your own catch will swallow the exception that's supposed to actually perform the redirect and nothing will happen.
A couple of smaller but easy-to-miss details: redirect can be called during a Client Component's render, but not from inside an event handler — for that case, useRouter (below) is the right tool. And it happily accepts absolute URLs, meaning it works fine as a mechanism for redirecting to genuinely external sites too, not only routes within your own app.
permanentRedirect: when the canonical URL itself changed
This one's easy to conflate with redirect, and the distinction is entirely about semantics, not mechanics — use it specifically when an entity's canonical address has permanently changed, like a user renaming their profile URL:
// app/actions.ts
"use server";
import { permanentRedirect } from "next/navigation";
import { revalidateTag } from "next/cache";
export async function updateUsername(username: string, formData: FormData) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidateTag("username", "max");
permanentRedirect(`/profile/${username}`);
}
The behavioral rule is identical to redirect in every way except the status code — 308 (Permanent Redirect) rather than 307 — which matters specifically because search engines and browsers treat a 308 as a signal to update their own stored references to the old URL, transferring accumulated SEO value to the new address rather than treating it as a temporary detour. Reach for permanentRedirect only when the old URL genuinely should stop being considered canonical going forward — using it for an ordinary temporary redirect tells search engines something that isn't true.
useRouter().push: the client-event-handler case
Both functions above work from Server Components, Server Functions, and Route Handlers — none of them work inside a Client Component's event handler, which is specifically what useRouter is for:
"use client";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
return (
<button type="button" onClick={() => router.push("/dashboard")}>
Dashboard
</button>
);
}
One easy trap worth naming directly: if you don't actually need programmatic navigation — if this is just "clicking this thing takes you to that page" with no logic in between — use a plain <Link> component instead. <Link> gets you automatic prefetching and the framework's optimized client-side transition handling for free; a button wired to router.push doesn't, and reaching for it out of habit where a <Link> would do costs you that optimization for no real benefit.
redirects in next.config.js: for redirects known ahead of time
When you already know a specific set of redirects at build time — you restructured your URLs, you're consolidating a set of old paths — config-level redirects handle this without any runtime code at all:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: "/about", destination: "/", permanent: true },
{ source: "/blog/:slug", destination: "/news/:slug", permanent: true },
];
},
};
export default nextConfig;
This supports path matching, and — a capability worth knowing exists even if you don't need it immediately — matching on headers, cookies, and query parameters too, giving genuine conditional flexibility without leaving the config file. The permanent boolean maps directly to the 307-vs-308 distinction from earlier, and it's worth setting deliberately rather than defaulting to true reflexively — a redirect you might reverse later should stay temporary.
Two operational details worth knowing before you lean on this heavily: redirects runs before Proxy in the request pipeline, so if you have both a config redirect and Proxy logic that could apply to the same path, the config-level one wins first. And platforms commonly cap how many entries this array can hold — Vercel specifically enforces a limit of 1,024 — which is exactly the constraint that pushes larger redirect sets toward the Proxy-based approach covered below.
NextResponse.redirect in Proxy: for conditional, request-time redirects
Proxy — the file that replaced Middleware in recent Next.js versions — runs code before a request completes, which makes it the right tool whenever the redirect decision depends on something only knowable at request time: authentication state, session data, feature flags, or (as covered next) a redirect list too large for next.config.js to hold.
// proxy.ts
import { NextResponse, NextRequest } from "next/server";
import { authenticate } from "auth-provider";
export function proxy(request: NextRequest) {
const isAuthenticated = authenticate(request);
if (isAuthenticated) {
return NextResponse.next();
}
return NextResponse.redirect(new URL("/login", request.url));
}
export const config = {
matcher: "/dashboard/:path*",
};
Worth remembering the ordering here too: Proxy runs after redirects in next.config.js, and before rendering begins. If a request survives both the config-level redirects and Proxy without being redirected, that's when actual page rendering starts.
Managing redirects at scale
Once you're past roughly a thousand redirects — a real scenario for any site that's undergone a significant URL restructuring, a platform migration, or years of incremental content reorganization — next.config.js's redirects() array stops being viable, both because of the platform limits mentioned above and because a config change of that size means redeploying the entire application just to add one redirect. The fix is moving the redirect logic into Proxy, backed by an external, updatable data source.
Storing the redirect map
A redirect map is just structured data — a JSON file or, more realistically at scale, a key-value store:
{
"/old": { "destination": "/new", "permanent": true },
"/blog/post-old": { "destination": "/blog/post-new", "permanent": true }
}
Read it in Proxy against something like Vercel's Global Config or Redis:
// proxy.ts
import { NextResponse, NextRequest } from "next/server";
import { get } from "@vercel/global-config";
type RedirectEntry = { destination: string; permanent: boolean };
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const redirectData = await get(pathname);
if (redirectData && typeof redirectData === "string") {
const entry: RedirectEntry = JSON.parse(redirectData);
return NextResponse.redirect(
entry.destination,
entry.permanent ? 308 : 307,
);
}
return NextResponse.next();
}
This solves the "redeploy to add a redirect" problem, but it introduces a new one worth taking seriously: Proxy runs on every single request to your app, so a slow lookup here becomes a latency tax paid by every visitor, on every page, whether or not their specific request needed a redirect at all.
Making the lookup fast enough to run on every request
Two complementary strategies: pick a backing store genuinely optimized for fast reads (Redis over a flat file, for instance), and — for the largest redirect sets — use a probabilistic pre-filter like a Bloom filter to cheaply answer "could this path possibly need a redirect" before paying for the more expensive definitive lookup.
// proxy.ts
import { NextResponse, NextRequest } from "next/server";
import { ScalableBloomFilter } from "bloom-filters";
import GeneratedBloomFilter from "./redirects/bloom-filter.json";
const bloomFilter = ScalableBloomFilter.fromJSON(GeneratedBloomFilter as any);
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
if (bloomFilter.has(pathname)) {
const api = new URL(
`/api/redirects?pathname=${encodeURIComponent(pathname)}`,
request.nextUrl.origin,
);
try {
const res = await fetch(api);
if (res.ok) {
const entry = await res.json();
if (entry) {
return NextResponse.redirect(
entry.destination,
entry.permanent ? 308 : 307,
);
}
}
} catch (error) {
console.error(error);
}
}
return NextResponse.next();
}
// app/api/redirects/route.ts
import { NextRequest, NextResponse } from "next/server";
import redirects from "@/app/redirects/redirects.json";
export function GET(request: NextRequest) {
const pathname = request.nextUrl.searchParams.get("pathname");
if (!pathname) {
return new Response("Bad Request", { status: 400 });
}
const redirect = (
redirects as Record<string, { destination: string; permanent: boolean }>
)[pathname];
if (!redirect) {
// Bloom filters can produce false positives — this is expected, not a bug
return new Response("No redirect", { status: 400 });
}
return NextResponse.json(redirect);
}
The architectural point of splitting this across two files is specifically about not loading the full redirects dataset into Proxy itself — Proxy only ever holds the small, fast Bloom filter, and only forwards to the Route Handler (which does hold the full dataset) for the comparatively rare paths the filter flags as possible matches. This keeps the cost paid on every request small, while the more expensive definitive check only runs for requests that plausibly need it.
Two things worth taking seriously if you build this pattern yourself: a Bloom filter can produce false positives by design — the if (!redirect) check in the Route Handler above is not defensive paranoia, it's an expected, routine outcome that needs handling, not an error condition. And because this Route Handler is now a public endpoint accepting arbitrary query input, validate what's actually being sent to it — an unvalidated redirect-lookup endpoint is a small but real surface worth not leaving wide open.
Key Takeaways
| Question to ask | Tool |
|---|---|
| Is this a consequence of a mutation my own code just performed? | redirect (temporary) or permanentRedirect (canonical URL changed) |
| Is this triggered from a Client Component event handler? | useRouter().push |
| Do I already know this redirect at build time? | redirects in next.config.js |
| Does the redirect depend on a request-time condition (auth, flags)? | Proxy + NextResponse.redirect |
| Do I have 1,000+ redirects to manage? | Proxy + external store, ideally behind a Bloom filter |
The five-tool split here isn't arbitrary complexity — each one maps to a genuinely different moment in the request lifecycle (build time vs. request time) and a genuinely different trigger (a URL you already know vs. a condition you can only check at runtime vs. a consequence of your own server code running). Picking the one that matches why you're redirecting, rather than whichever one happens to be reachable from wherever you're currently writing code, is what keeps a growing redirect strategy manageable instead of accumulating into a pile of ad hoc special cases.


