Auth.js v5 on Next.js 16, step by step: middleware.ts to proxy.ts
By VibeBoot ·
Short on time? The SaaS Starter already has this wired up: $49 one-time, 14-day money-back guarantee.
Next.js 16 renamed middleware.ts to proxy.ts. If you're adding Auth.js v5 to a new Next.js 16 app, or upgrading one that already has it, most of the setup stays the same. The file around the request check is what changes. This guide covers the whole setup in order and points out the parts that changed.
Everything below was checked on Oct 27, 2026 against the Next.js proxy docs, the Auth.js protecting resources guide and a real project on Next.js 16.4.3 with next-auth 5.0.0-beta.32.
What actually changed in Next.js 16
From the Next.js docs version history: in v16.0.0, "Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime." In practice:
- The file name.
middleware.tsbecomesproxy.ts. It goes in the same place: the project root, orsrc/if your app lives there, next toapp/. - The export name.
export function middleware()becomesexport function proxy(). A default export also works. - The runtime. Proxy runs on Node.js by default. You can't set the
runtimeconfig option in a proxy file. The docs say setting it throws an error, so delete anyexport const runtime = "edge"line. - Config flags.
skipMiddlewareUrlNormalizeis nowskipProxyUrlNormalize. - Everything else stays.
config.matcher,NextResponse, cookies, headers, rewrites and redirects all work the same.
Next.js ships a codemod that renames the file and the function:
npx @next/codemod@canary middleware-to-proxy .If you'd rather paste your file and see the diff, the free middleware.ts → proxy.ts converter does the same rename in your browser and flags the lines you need to look at by hand.
Step 1: install Auth.js v5
npm install next-auth@beta
npx auth secretnpx auth secret generates AUTH_SECRET and adds it to your env file. Without it you get MissingSecret at runtime.
Step 2: one config file, src/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
trustHost: true,
});trustHost: true matters if you run next start yourself, in Docker or behind a reverse proxy. Without it Auth.js throws UntrustedHost. Import auth, signIn and signOut from @/auth everywhere. Don't call NextAuth() a second time.
Step 3: the route handler
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;This part is the same as it was on Next.js 15.
Step 4: proxy.ts (this used to be middleware.ts)
The Auth.js docs give the Next.js 16 version as a one-liner:
// src/proxy.ts
export { auth as proxy } from "@/auth";On Next.js 15 that same line was export { auth as middleware } from "@/auth" in middleware.ts. With the one-liner, add an authorized callback in auth.ts to decide who gets through:
callbacks: {
authorized: async ({ auth }) => !!auth,
},If you need your own logic, wrap it:
// src/proxy.ts
import { auth } from "@/auth";
export const proxy = auth((req) => {
if (!req.auth && req.nextUrl.pathname !== "/login") {
return Response.redirect(new URL("/login", req.nextUrl.origin));
}
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Keep a matcher. With no matcher, proxy runs on every request, including _next/static, images and public/ files. An auth redirect there breaks your CSS and JS. Also leave your Stripe webhook route out of it. Stripe doesn't send a session cookie, so a redirect there will fail every delivery.
Step 5: check the session where the data is
This is the step that agents skip. Both the Next.js and Auth.js docs say not to rely on proxy alone. In the Next.js docs' words, a matcher change or a refactor that moves a Server Function "can silently remove Proxy coverage." Server Functions aren't separate routes: they're POSTs to the page that uses them. So a matcher that skips a path skips its actions too.
So every protected page, route handler and server action checks the session itself:
// src/app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function Dashboard() {
const session = await auth();
if (!session?.user) redirect("/login");
// query with session.user.id, never an id from the URL
}Think of proxy as the fast, optimistic redirect. The auth() call next to the query is the real check.
Upgrading an existing app: checklist
- Run the codemod, or rename
middleware.ts→proxy.tsandmiddleware→proxyyourself export { auth as middleware }becomesexport { auth as proxy }- Delete
export const runtime = "edge"from the proxy file - Rename
skipMiddlewareUrlNormalizeinnext.config.tsif you use it - Make sure the matcher skips static files and webhooks
- Grep for
await auth()in every protected page, route handler and server action - Update your AGENTS.md or Cursor rules so your agent stops creating
middleware.ts
That last step matters more than it sounds. Agents trained on older Next.js code will happily create a fresh middleware.ts next to your proxy.ts. Next.js 16.3+ helps here: next dev adds a managed block to AGENTS.md that points agents at the docs bundled with your installed version. See what to put around that block.
A Cursor prompt for the upgrade
This is a Next.js 16 app with Auth.js v5 (next-auth beta). Rename middleware.ts to
proxy.ts and the exported middleware function to proxy (or export { auth as proxy }
from "@/auth"). Remove any runtime = "edge" export from that file. Keep the matcher,
and make sure it skips _next/static, _next/image, favicon.ico and the Stripe webhook
route. Then list every page, route handler and server action that reads user data and
confirm each one calls await auth() itself. Do not create a middleware.ts file.The SaaS Starter already does this
The VibeBoot SaaS Starter runs on Next.js 16.4.3 with Auth.js v5 and already uses this layout. Its src/proxy.ts exports proxy and has a matcher limited to /login and /api/auth/:path*, where it rate-limits sign-in POSTs. It has no middleware.ts. The dashboard and the Stripe checkout and portal routes each call await auth() themselves. Auth.js is configured once in src/auth.ts (trustHost: true, Prisma adapter, database sessions), and AGENTS.md tells agents to import from @/auth. Stuck on a specific Auth.js error? Paste it into the free Auth.js error fixer.