Type something to search...
Next.js unauthorized

Next.js unauthorized

Every app with a login screen eventually needs a clean way to say "you're not signed in, go log in" from deep inside a Server Component, a Server Action, or a Route Handler — without hand-rolling a redirect, a thrown error, and a matching error boundary every single time. Next.js ships a purpose-built primitive for exactly this: the unauthorized() function. Call it, and Next.js takes care of stopping the render, marking the page as non-indexable, and showing a dedicated 401 UI in its place.

It's easy to confuse unauthorized() with its sibling forbidden(), or to assume it behaves like redirect() under the hood. It doesn't. This article is the focused reference for the function itself — its exact throwing behavior, where you're allowed to call it from, the gotchas around try/catch and un-awaited promises, and the handful of real usage patterns you'll actually reach for. If you want the UI side of this (the unauthorized.js file that renders when this function fires), that's covered separately — this piece is about the function you call to get there.

What unauthorized() actually does

Calling unauthorized() throws a special error — NEXT_HTTP_ERROR_FALLBACK;401 — and that throw immediately terminates rendering of the route segment it was thrown from. Next.js catches that specific error internally, and instead of letting it bubble up as a crash, it renders the nearest unauthorized.js file in its place. Along the way, it also injects <meta name="robots" content="noindex" /> into the response, so search engines don't index a page that's telling visitors they aren't allowed to see it.

Because the mechanism is a throw, not a return value, unauthorized() has a TypeScript return type of never. That has a very practical implication: you never write return unauthorized(). Just calling unauthorized() on its own line is enough — execution stops right there, and nothing after it in that function ever runs.

// app/dashboard/page.tsx
import { verifySession } from "@/app/lib/dal";
import { unauthorized } from "next/navigation";

export default async function DashboardPage() {
  const session = await verifySession();

  if (!session) {
    unauthorized();
  }

  // Everything below only runs if the user is authenticated
  return (
    <main>
      <h1>Welcome to the Dashboard</h1>
      <p>Hi, {session.user.name}.</p>
    </main>
  );
}

It's experimental, and it requires a flag

As of this writing, unauthorized() is still an experimental API, and the Next.js team explicitly recommends against relying on it in production until it stabilizes. To even use it, you have to opt in with the authInterrupts flag in next.config.js:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    authInterrupts: true,
  },
};

export default nextConfig;

Without that flag enabled, calling unauthorized() won't work as documented — so if you copy an example from somewhere and it silently doesn't behave the way you expect, check this first. This same flag also gates the sibling forbidden() function, so enabling it gives you both interrupts at once.

Where you can (and can't) call it

unauthorized() is valid inside:

  • Server Components — the most common place you'll use it, typically guarding a page or a nested component.
  • Server Functions (Server Actions) — to reject a mutation from an unauthenticated caller before it touches your database.
  • Route Handlers — to return a 401 experience from an API-style endpoint instead of a bare JSON error.

The one place it's explicitly disallowed is the root layout. That makes sense once you think about what a root layout is: it wraps every single route in your app, including the unauthorized.js boundary itself. If the root layout could throw unauthorized(), there would be no valid place left for the fallback UI to render into — you'd create an unrecoverable loop. If you need an app-wide gate, put the check one level down, in a route group's layout or in middleware/proxy instead.

The try/catch trap

Because unauthorized() works by throwing, wrapping the call — or anything that transitively calls it — in a try/catch will silently swallow the interrupt. Your catch block executes, the 401 UI never renders, and depending on what your catch block does, the user might see a generic error, a blank state, or nothing informative at all.

// Silently breaks unauthorized()
try {
  const data = await getProtectedData(); // calls unauthorized() internally
} catch (err) {
  console.error(err); // swallows the interrupt — no 401 UI ever shows
}

If you have a try/catch around code that might call unauthorized() for other reasons (say, you're also catching real exceptions from a database call), use unstable_rethrow to let Next.js's internal control-flow errors pass through untouched while still catching everything else:

import { unstable_rethrow } from "next/navigation";

try {
  const data = await getProtectedData();
} catch (err) {
  unstable_rethrow(err); // re-throws Next.js's internal signals (unauthorized, redirect, notFound, etc.)
  // anything reaching here is a genuine, unrelated error
  console.error(err);
}

The un-awaited promise gotcha

This one is subtle and easy to miss during code review. If a function that calls unauthorized() internally is invoked but not awaited, the throw happens inside a promise that nothing is listening to. The result: no unauthorized.js UI renders, and in development you'll see a fairly cryptic log —

⨯ unhandledRejection: NEXT_HTTP_ERROR_FALLBACK;401

— with no visible failure in the UI itself. The fix is simple but easy to forget: always await any function on the path to unauthorized().

// Bug: missing await means the throw has nowhere to go
getAccount(); // unauthorized() inside here fires into the void

// Correct
await getAccount();

Streaming changes what status code the user actually gets

This is the single most important nuance in the whole API, and it's easy to get wrong if you're chasing "instant" page loads with <Suspense>.

If you call unauthorized() from inside a component wrapped in <Suspense>, the page shell around that boundary has almost certainly already started streaming to the browser as a 200 OK — before the auth check even ran. Once a response starts streaming, its HTTP status code can't be changed retroactively. So the visitor does see your unauthorized.js UI, but the actual response the browser (and any bot, monitoring tool, or status-code-sensitive client) received was 200, not 401.

// app/account/page.tsx
import { Suspense } from "react";
import { verifySession } from "@/app/lib/dal";
import { unauthorized } from "next/navigation";

async function getAccount() {
  const session = await verifySession();
  if (!session) {
    unauthorized();
  }
  return db.accounts.findByUserId(session.userId);
}

async function AccountDetails() {
  const account = await getAccount();
  return <p>Signed in as {account.email}</p>;
}

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <AccountDetails />
      </Suspense>
    </main>
  );
}

For most page-level scenarios this trade-off is fine — a human visitor sees the correct message either way. But if you actually need the real 401 status code on the wire (an API consumer checking response.status, a health check, an integration test asserting on status codes), the check has to run before streaming starts. With the Cache Components model, every dynamic route already streams a static shell first regardless of where you put your check, which means a component-level unauthorized() call can never guarantee a pre-stream 401. In that case, move the check into proxy instead, which runs before any part of the response is sent.

Common usage patterns

Guarding a whole page:

export default async function DashboardPage() {
  const session = await verifySession();
  if (!session) unauthorized();
  return <div>Dashboard</div>;
}

Paired with an app/unauthorized.tsx that renders a login prompt:

// app/unauthorized.tsx
import Login from "@/app/components/Login";

export default function UnauthorizedPage() {
  return (
    <main>
      <h1>401 - Unauthorized</h1>
      <p>Please log in to access this page.</p>
      <Login />
    </main>
  );
}

Rejecting an unauthenticated mutation in a Server Action:

"use server";

import { verifySession } from "@/app/lib/dal";
import { unauthorized } from "next/navigation";

export async function updateProfile(data: FormData) {
  const session = await verifySession();
  if (!session) {
    unauthorized();
  }
  // proceed with the mutation
}

Gating a Route Handler:

// app/api/profile/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/app/lib/dal";
import { unauthorized } from "next/navigation";

export async function GET(req: NextRequest): Promise<NextResponse> {
  const session = await verifySession();
  if (!session) {
    unauthorized();
  }
  // fetch and return data
}

unauthorized() vs. forbidden() vs. redirecting to login

These three approaches solve overlapping-sounding problems, but they mean different things, and mixing them up sends the wrong signal to both users and any automated client hitting your app.

FunctionHTTP semanticsUse it when...
unauthorized()401 — not authenticatedThe visitor has no valid session at all; they need to log in
forbidden()403 — authenticated but not permittedThe visitor is logged in, but their role/permissions don't allow this action or resource
redirect("/login")307/303 — navigate elsewhereYou want the browser to actually move to a different URL, rather than show an inline 401 state

A common mistake is using redirect() to a /login route for every auth failure, including ones inside API-style Route Handlers, where a JSON-consuming client has no use for a redirect and really just wants a 401 status it can check programmatically. unauthorized() gives you that structured signal without hand-rolling a NextResponse.json({ error: "..." }, { status: 401 }) every time.

Common mistakes

  • Forgetting the authInterrupts flag. The function silently does the wrong thing (or errors in a confusing way) if the experimental flag isn't enabled.
  • Wrapping the call path in try/catch without unstable_rethrow. This is the single most common way teams accidentally disable their own auth guard.
  • Not awaiting the function that calls unauthorized(). Leads to an unhandled rejection instead of a rendered 401 page.
  • Assuming a component-level call guarantees a real 401 status code. Once streaming has started, it hasn't — check in proxy if the status code itself matters to a caller.
  • Calling it from the root layout. Not supported, and there's no valid fallback boundary above it to catch it.

Key Takeaways

AspectBehavior
MechanismThrows NEXT_HTTP_ERROR_FALLBACK;401, return type never
Requiresexperimental.authInterrupts: true in next.config.js
Valid contextsServer Components, Server Functions, Route Handlers
Invalid contextRoot layout
SEO effectInjects <meta name="robots" content="noindex" />
try/catchSwallows the interrupt — use unstable_rethrow if you must catch other errors nearby
Un-awaited callThrows into an unhandled rejection, no UI renders
Status code with <Suspense>Response has likely already streamed as 200; move the check to proxy for a real 401
Introducedv15.1.0

unauthorized() is a small API with a lot of sharp edges hiding in the details — the throw semantics, the try/catch trap, and the streaming status-code subtlety are all things that work perfectly until the one time they don't. Once you internalize that it's a control-flow throw rather than a return value, and that HTTP status codes are locked in the moment streaming starts, the rest of the API is genuinely straightforward.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper