Skip to main content
ASoc
Tutorial

Next.js 13 Authentication on Next 16: What Changed

Map every Next.js 13 auth step to Next 16: middleware is now proxy, cookies() is async, and getClaims() replaces unverified session reads.

The ASoc Team8 min read

Next.js 13 authentication tutorials still rank, but three of their load-bearing steps have changed by Next 16: middleware.ts is now proxy.ts, cookies() is asynchronous, and trusting a session cookie without verifying it is the one pattern to drop. This storefront runs the current shape in 42 lines of proxy, a 45-line layout guard and 158 lines of Server Actions, and this post maps each Next 13 step onto what it became.

The short answer

Next.js 13 introduced the App Router and Server Components, and every auth guide since then is a variation on the same three jobs: refresh the session on each request, check it in a server-rendered layout, and run sign-in as a server mutation. What moved between 13 and 16 is where those three jobs live and how strictly the session is verified.

JobNext 13 tutorialNext 16, as shipped in this repo
Refresh session per requestmiddleware.tssrc/proxy.ts (the file's own comment calls it "Next 16 proxy (formerly middleware)")
Read cookies on the servercookies() used synchronouslyawait cookies() — async
Check "is this user signed in"Read the sessionsupabase.auth.getClaims(), which validates the JWT
Guard a route groupCheck in each page, or in middlewareOne layout.tsx guard for everything under /dashboard
Sign inClient fetch to an API routeServer Action returning a typed state
Finish OAuth / email linksAPI routeOne PKCE callback Route Handler

This repo uses Supabase for identity, so the code below is Supabase's. The structure is what carries over if you use another provider; where we say something is provider-specific, it is. For the deeper Supabase walkthrough see Supabase Auth in Next.js; this post is the migration map.

Step 1: the file that used to be middleware

In a Next 13 tutorial, session refresh lives in middleware.ts. In Next 16 the same job is src/proxy.ts, and the exported function is named proxy:

// src/proxy.ts
export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  await supabase.auth.getClaims();
  return response;
}

The reason this exists at all is that a Server Component cannot write cookies, so something earlier in the request has to persist a rotated token. The proxy does, and the matcher at the bottom of the file skips static assets and the crawler routes (sitemap.xml, robots.txt) so they pay nothing for it.

One cost worth knowing before you copy it: this runs on every matched request. In our dev run the proxy took 326 ms on the first, cold request to /pricing, and it throws site-wide if NEXT_PUBLIC_SUPABASE_URL is unset — which is why the project's notes forbid deploying without those variables. Next.js env covers that failure mode.

Step 2: reading cookies is now async

Next 13 code reads cookies directly. Next 16 requires you to await them, and every server-side client helper has to follow:

// src/lib/supabase/server.ts
export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(/* url, anon key */ {
    cookies: {
      getAll: () => cookieStore.getAll(),
      setAll: (cookiesToSet) => {
        try {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options),
          );
        } catch {
          // Called from a Server Component render (cookies are read-only there);
          // the proxy refreshes the session cookie instead.
        }
      },
    },
  });
}

The empty catch is not sloppiness. Writing a cookie during a Server Component render throws, and the proxy from step 1 is what makes ignoring it safe. Delete either half and you get a session that silently stops refreshing.

Step 3: verify, do not just read

The most important change is not a rename. Many 13-era snippets call getSession() and branch on the result. That reads whatever is in the cookie, so it proves nothing about who sent it. This repo's rule, recorded in the layout guard, is the opposite:

// src/app/dashboard/layout.tsx
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
  redirect("/login?next=/dashboard");
}

getClaims() validates the JWT, so a forged cookie fails. And because it sits in a layout, every route beneath /dashboard is covered by one check — src/app/dashboard/ has a page.tsx and a settings route, and neither repeats the test. The guard also sets robots: { index: false, follow: false } for the whole subtree, a detail tutorials skip.

A caution that applies to any layout-based guard: layouts do not re-render on client-side navigation between their children, so the layout check is a first-load gate. Anything that returns private data must verify again where the data is read — which is why this project's download route re-runs its authorization on every request instead of trusting the dashboard.

Step 4: sign-in as a Server Action

Instead of a client component calling an API route, src/lib/actions/auth.ts exports Server Actions. The sign-in one returns a small typed state the form renders:

export type AuthState = { ok: boolean; message: string } | null;

const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
  console.error("auth: signInWithPassword error", error);
  // Always the same message — never let a caller distinguish "wrong
  // password" from "email exists but unconfirmed" (account enumeration).
  return { ok: false, message: "Incorrect email or password." };
}
return { ok: true, message: "Signed in — redirecting…" };

The log keeps the real error server-side; the user always sees one message. That single-message rule is a security decision that is easy to lose when copying a tutorial that echoes the provider's error text.

Email confirmation, Google sign-in and password recovery all end by returning to src/app/auth/callback/route.ts, which exchanges a one-time code for a session and then redirects. Only a Route Handler or Server Action can persist that cookie, which is why it is a route and not a page. The redirect target passes through safeNext() in src/lib/validation.ts, which falls back to /dashboard for anything that is not a safe relative path, so the ?next= parameter cannot be turned into an open redirect.

Troubleshooting

SymptomCauseFix
middleware.ts ignored or warned aboutNext 16 renamed itMove to proxy.ts, export proxy
Type error on cookies()It is async nowawait cookies() and make the helper async
User signed out after about an hourNothing refreshes the tokenCall getClaims() in the proxy
Guard passes with a tampered cookieUsed getSession()Switch to getClaims()
Every route 500s after deployProxy reads an unset env varSet the Supabase variables first
Cookie set throws in a pageServer Components cannot write cookiesWrap in try/catch; refresh in the proxy
Login redirects to an attacker URL?next= used uncheckedValidate with an allowlist pattern
Private data visible after sign-out in a tabLayout guard only runs on first loadRe-verify where the data is fetched

Frequently asked questions

Is Next.js 13 authentication still valid on Next 16? The architecture is. The mechanics are not: rename middleware to proxy, await cookies(), and replace unverified session reads with verified ones.

Should I use NextAuth or a provider SDK? We have not run NextAuth in this repo, so we cannot compare. Our reason for a provider SDK was that identity, row-level security and storage then share one user id; if you do not need that, evaluate the options on your own constraints.

Where should the auth check live? Both: a layout guard for the redirect experience, and a check at every place private data is read. The layout alone is not a security boundary.

Do I need a database policy as well? If your database supports row-level security, yes — this project's read policies are one line each, so the cost of adding them is low.

Templates in this post

ASoc Reach (an AI-marketing agency landing page), ASoc Realm (a property-management SaaS site) and ASoc Relay (a messaging-platform SaaS site) are marketing templates that stop at the sign-in link, so the pattern above is what you add behind their call-to-action.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the access-token side, see Supabase check if user is logged in.

Keep reading

Tutorial11 min read

Next.js 16 Renamed Middleware to Proxy: What Changes and What Breaks

Renaming the file is a third of the migration. The exported function has to change too, keeping both files is a build error, and the proxy runs on Node rather than the Edge.

Read more
Tutorial11 min read

A/B Testing in Next.js 16: The Proxy Recipe Costs the CDN, Not SSG

Rewriting in middleware does not make your pages dynamic — both variants stay prerendered. What it really costs is the shared cache and every request's critical path.

Read more
Tutorial11 min read

An Accessible Mega Menu in Next.js Without a Headless UI Library

It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.

Read more