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.
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.
| Job | Next 13 tutorial | Next 16, as shipped in this repo |
|---|---|---|
| Refresh session per request | middleware.ts | src/proxy.ts (the file's own comment calls it "Next 16 proxy (formerly middleware)") |
| Read cookies on the server | cookies() used synchronously | await cookies() — async |
| Check "is this user signed in" | Read the session | supabase.auth.getClaims(), which validates the JWT |
| Guard a route group | Check in each page, or in middleware | One layout.tsx guard for everything under /dashboard |
| Sign in | Client fetch to an API route | Server Action returning a typed state |
| Finish OAuth / email links | API route | One 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.
Step 5: one callback for every emailed or redirected link
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
| Symptom | Cause | Fix |
|---|---|---|
middleware.ts ignored or warned about | Next 16 renamed it | Move to proxy.ts, export proxy |
Type error on cookies() | It is async now | await cookies() and make the helper async |
| User signed out after about an hour | Nothing refreshes the token | Call getClaims() in the proxy |
| Guard passes with a tampered cookie | Used getSession() | Switch to getClaims() |
| Every route 500s after deploy | Proxy reads an unset env var | Set the Supabase variables first |
Cookie set throws in a page | Server Components cannot write cookies | Wrap in try/catch; refresh in the proxy |
| Login redirects to an attacker URL | ?next= used unchecked | Validate with an allowlist pattern |
| Private data visible after sign-out in a tab | Layout guard only runs on first load | Re-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.
