Skip to main content
ASoc
Tutorial

Next.js Redirects: Three APIs, and Why We Use Two of Them

Thirteen redirects, eight files, zero next.config.ts entries. Why every redirect() call here depends on auth state config-based redirects cannot see.

The ASoc Team8 min read

Next.js gives you three different ways to redirect, and they are not interchangeable: redirect() from next/navigation throws inside Server Components, Server Actions and Route Handlers; NextResponse.redirect() returns a Response object and only works in a Route Handler or middleware; and redirects() in next.config.ts rewrites a known path at the routing layer before your code runs at all. This storefront uses the first two and deliberately not the third — thirteen redirects across eight files, and every single one is conditional on something next.config.ts can't see: whether you're signed in.

Why this codebase has zero config-based redirects

next.config.ts's redirects() option is for static, known-ahead-of-time path mappings — /old-url always goes to /new-url, for every visitor, forever, decided at build/deploy time. Grep this repo's config and there's nothing there, which is a real absence worth explaining rather than an oversight: every redirect in this app depends on request-time state — whether getClaims() returns a session, whether a password-reset code is present, whether a download URL just got signed. None of that exists when next.config.ts is evaluated. The config-based option simply doesn't have a slot for "redirect this path, but only for logged-out visitors."

The one redirect here that looks static — an old bookmark to a moved page — still isn't in the config, and the reason is instructive:

// src/app/dashboard/settings/page.tsx
import { redirect } from "next/navigation";

/** `/dashboard/settings` moved into the Settings tab of `/dashboard` — redirect old links/bookmarks. */
export default function SettingsRedirectPage() {
  redirect("/dashboard?tab=settings");
}

This could be a next.config.ts entry — the source and destination never change per request. It's a page-level redirect() instead because /dashboard/settings is itself an auth-gated route (ƒ, dynamic), and a config-level redirect would fire before the auth check ever ran, sending a logged-out visitor to /dashboard?tab=settings only to immediately bounce again off the dashboard's own guard. Keeping it as a page keeps the redirect and the auth check in the same place.

The auth-guard pattern: redirect() in a layout

The most common shape in this codebase is a session check that redirects before anything renders:

// src/app/dashboard/layout.tsx
export default async function DashboardLayout({ children }: { children: ReactNode }) {
  const supabase = await createClient();
  const { data } = await supabase.auth.getClaims();
  if (!data?.claims) {
    redirect("/login?next=/dashboard");
  }
  // ...
}

redirect() called from a layout guards every route beneath it in one place, rather than repeating the check in every page under /dashboard. The ?next=/dashboard query param is what makes this round-trip correctly: /login reads it and hands it forward through the auth actions, so a visitor who got redirected here lands back where they were headed, not just at /dashboard's root.

redirect() also takes an absolute URL — including someone else's

Every tutorial example redirects to a path on your own site. Two real call sites here redirect to a URL that isn't ours at all:

// src/lib/actions/auth.ts — Google OAuth kickoff
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: "google",
  options: { redirectTo: `${SITE_URL}/auth/callback?next=${encodeURIComponent(next)}` },
});
if (error || !data?.url) {
  redirect("/login?error=auth");
}
redirect(data.url);

data.url is Google's own consent-screen URL, not a route in this app — redirect() accepts it unmodified because the function's contract is "send a 307/303 to this URL," full stop, with no same-origin requirement. The callback route on the way back constructs its own absolute URL for the same reason:

// src/app/auth/callback/route.ts
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
  redirect(`${origin}${next}`);
}
console.error("auth/callback: exchangeCodeForSession error", error);
redirect(`${origin}/login?error=auth`);

Two branches, two destinations, both built from origin rather than left as relative paths — deliberate, because a Route Handler has no implicit base URL the way a <Link href> does inside the render tree.

redirect() vs NextResponse.redirect() — the Route Handler split

/api/download is the one place in this codebase that reaches for the other redirect:

// src/app/api/download/route.ts
if (result.status === 302) {
  return NextResponse.redirect(result.url, 302);
}

redirect() from next/navigation works by throwing a special error Next.js catches further up the call stack — it never returns, and the function has no return type worth naming. A Route Handler is a plain async function that Next.js expects to return a Response, and api/download already branches on several other status codes (401, 403, 429) that need an explicit return — mixing a throwing redirect() into that branch structure would be the odd one out. NextResponse.redirect(url, status) fits the same "return a Response" shape as every other branch in the function, and lets the caller pick the status code explicitly (this route's redirect is a 302, not the 307 a plain redirect() defaults to for a Server Action).

The gotcha this codebase avoids: redirect() inside try/catch

redirect()'s throw is the most common way people accidentally break it. Because the mechanism is "throw an error Next.js recognizes," wrapping the call in your own try/catch intercepts that error before Next.js ever sees it — the redirect silently never happens, and whatever your catch block does runs instead. None of the twelve redirect() call sites in this codebase sit inside a try/catch; the pattern used everywhere is to check the error branch first, log it, and call redirect() as an unguarded statement afterward — exactly the shape in the OAuth kickoff and callback-route examples above. If you need to redirect from inside error-handling logic, call it after the catch block closes, not within it.

Common mistakes

MistakeSymptomFix
redirect() inside a try/catchRedirect silently never firesCall redirect() after the catch block, not inside it
Using next.config.ts redirects() for a per-user destinationCan't express "only if logged out" — config has no request contextA redirect() call in a layout or page instead
redirect() in a Route Handler that also needs custom status codesAwkward mixing of throw-based and return-based control flowNextResponse.redirect(url, status) for Route Handlers that already return varied statuses
Relative path passed to redirect() from a Route HandlerWorks, but implicit base URL can surprise you outside the render treeBuild an absolute URL from origin/SITE_URL explicitly
Forgetting the ?next= round-trip on an auth-guard redirectUser lands at the dashboard root instead of the page they wantedCarry the original path as a query param and read it back after login
Assuming redirect() returnsCode after the call executes when you didn't expect it to for TypeScript's control-flow narrowingredirect()'s return type is never — treat it as the end of the function

Frequently asked questions

Does redirect() cost anything in bundle size or make the route dynamic? redirect() from next/navigation is a tiny runtime helper, not a dependency — its cost is architectural, not bytes: calling it from a Server Component or layout forces that segment to render on the server per request (it has to evaluate the condition before it can decide whether to throw), which is one of the four ways this codebase's own routes end up server-rendered on demand rather than static. We catalogued all four triggers, including this one, in the static-rendering post.

What HTTP status code does redirect() send? 307 (Temporary Redirect) from a Route Handler or Server Action, which preserves the request method — a POST stays a POST after following it. That's separate from the optional second argument redirect() accepts ('push' or 'replace'), which controls client-side history behavior, not the status code. For a permanent 308, Next.js ships a distinct permanentRedirect() function rather than a flag on redirect(); NextResponse.redirect(url, status) is the one that takes an explicit numeric status directly, which is why /api/download reaches for it.

Can I redirect and still show a message, like "check your email"? Yes — carry the message as a query param and read it on the destination page, the same way account.ts does with ?verification=sent / ?verification=error. redirect() throws immediately, so there's no way to also return state from the same function call; the URL is the only channel left.

Is redirect() different in a Server Action versus a Server Component? Functionally no — same throw, same status codes — but a Server Action's redirect happens after the mutation completes, so anything before the redirect() call (a database write, a Supabase signOut()) is guaranteed to have finished. A Server Component's redirect(), like the one in dashboard/layout.tsx, runs during render, before any of that segment's children start rendering at all.

Templates where this pattern already ships

ASoc Estate is a real-estate management admin — agents, listings and workspace apps across 3 dashboards — with the same auth-gated-layout shape this post's redirect guard protects. ASoc Lura spans 11 dashboards and roughly 177 pages across every industry vertical, another surface where a single layout-level redirect guard is worth more than repeating the check per page. ASoc Scholar is the largest of the three at 13 dashboards and 210+ pages, if you want the pattern proven at scale.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the wider question of which routes end up server-rendered at all, read the four things that force a route dynamic in the App Router, and for the session-refresh half of this same auth flow, migrating this storefront's middleware to Next.js 16's proxy.ts.

Keep reading

Tutorial9 min read

Next.js Rewrites: Three Phases, and the Four We Turned Down

318 pages and zero rewrites, with the compiled manifest to prove it. What each phase beats, why our seven hub pages stayed files, and the 308 redirect nobody configured.

Read more
Tutorial9 min read

Next.js Route Groups: Why This 24-Route App Uses Zero

Route groups hide a folder from the URL. Every one of this app's 24 top-level folders needs to be in the URL — which is the exact case they're not for.

Read more
Tutorial12 min read

Next.js Routing: 27 Page Files, 412 URLs, Three Conventions Unused

A census of one production app/ tree: which of the nine reserved filenames actually earn a file, and the 40 links, 13 redirects and 2 routers that move between them.

Read more