If you searched for "Next.js middleware authentication" recently, every tutorial you found was probably wrong. Not because the patterns are bad. Wrong because they reference a file that no longer exists in Next.js 16.
Next.js 16 deprecated middleware.ts and renamed it proxy.ts. The exported function changes too: middleware becomes proxy. Every authentication guide written before this change now points to a deprecated convention, and most of them haven't been updated. If you're starting a new project, or migrating an existing one, here is what you actually need to know.
This guide covers the migration from middleware.ts to proxy.ts, a complete Supabase authentication implementation using the new convention, and one security constraint that most tutorials skip: proxy alone doesn't protect your Server Actions.
Table of Contents
- What Changed in Next.js 16
- Migrating middleware.ts to proxy.ts
- Supabase Authentication in proxy.ts
- Route Protection Patterns
- The Two-Layer Security Model
- Authorizing Server Actions
- What proxy.ts Is Actually For
What Changed in Next.js 16
The change is mostly cosmetic. The behavior of proxy is identical to middleware, but three things are different [1].
The file name. middleware.ts is now proxy.ts. Place it at the project root, or inside src/ if you use that layout, at the same level as your app/ directory.
The function name. The exported function changes from middleware to proxy. You can use either a named export or a default export.
The runtime. Proxy now defaults to the Node.js runtime. Prior to Next.js 15.2, middleware ran exclusively on the Edge runtime, which created compatibility issues with @supabase/ssr. That package requires Node.js APIs. The stable Node.js runtime (introduced in 15.5) removes that friction entirely. No workarounds, no custom runtime config.
Everything else is identical: NextRequest, NextResponse, the config export with its matcher property, and all the request/response manipulation APIs.
The Next.js team explains the rename directly [2]:
"The term 'middleware' can often be confused with Express.js middleware, leading to a misinterpretation of its purpose. The name Proxy clarifies what it is capable of: a network boundary in front of the app."
That framing is worth internalizing. Proxy sits between the client and your application code. It intercepts every matching request before any page or route handler runs.
Migrating middleware.ts to proxy.ts
Next.js provides a codemod for the migration:
npx @next/codemod@canary middleware-to-proxy .
The codemod does two things: renames the file from middleware.ts to proxy.ts, and renames the exported function. If you'd rather migrate by hand, here is the full diff:
// Before: middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
}
// After: proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
}
Two lines change. The file name and the function name. The config export, matcher patterns, and all NextRequest/NextResponse APIs stay identical.
For TypeScript users, Next.js exports a NextProxy type that infers both parameters automatically:
// proxy.ts
import type { NextProxy } from 'next/server'
export const proxy: NextProxy = (request, event) => {
event.waitUntil(Promise.resolve())
return NextResponse.next()
}
Supabase Authentication in proxy.ts
Here is a complete proxy.ts implementation for Supabase authentication. The cookie handling is the critical part: Supabase stores session tokens in cookies, and the proxy needs to both read and write them correctly.
// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
const protectedRoutes = ['/dashboard', '/settings', '/billing']
const authRoutes = ['/login', '/signup', '/reset-password']
const adminRoutes = ['/admin']
export async function proxy(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value)
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options)
})
},
},
}
)
// Always call getUser() to refresh the session token if expired
const {
data: { user },
} = await supabase.auth.getUser()
const path = request.nextUrl.pathname
// Redirect unauthenticated users away from protected routes
if (protectedRoutes.some((route) => path.startsWith(route)) && !user) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('next', path)
return NextResponse.redirect(loginUrl)
}
// Admin routes: require auth and verified admin status
if (adminRoutes.some((route) => path.startsWith(route))) {
if (!user) {
return NextResponse.redirect(new URL('/login', request.url))
}
const adminEmails = process.env.ADMIN_EMAILS?.split(',') ?? []
if (!adminEmails.includes(user.email!)) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
}
// Redirect authenticated users away from auth routes
if (authRoutes.some((route) => path.startsWith(route)) && user) {
const next = request.nextUrl.searchParams.get('next')
const destination =
next && next.startsWith('/') && !next.startsWith('//')
? next
: '/dashboard'
return NextResponse.redirect(new URL(destination, request.url))
}
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Two implementation details deserve explanation.
The setAll implementation. When Supabase refreshes a session token, it needs to write the updated cookie to the response. If you skip the setAll block and only implement getAll, the session token won't refresh when it expires. Users get logged out unexpectedly, and the root cause is hard to diagnose because it looks like a session that just ended rather than a cookie that didn't update.
The getUser() call. This contacts the Supabase auth service to verify the session token is still valid. It's slightly slower than reading a JWT locally, but it's also the only way to detect revoked sessions. A user whose account was suspended won't be caught by a locally-validated JWT. getUser() checks against the live session state.
Route Protection Patterns
The implementation covers three distinct cases.
Protected routes are pages that require authentication. The redirect includes the original path as a next parameter, so after login the user lands where they intended rather than at a generic dashboard.
Auth routes are pages that only make sense for logged-out users. Redirecting an authenticated user away from /login prevents a common bug: a user with a valid session still sees the login form and can submit it, which creates a confusing duplicate-session state.
Admin routes add a second check beyond authentication. A valid session isn't sufficient. The user's email must appear in a list of authorized addresses. Keep this list in an environment variable rather than hardcoded. You'll need to rotate it without redeploying.
One pattern worth adding for production: handle the next redirect target defensively. Only redirect to paths that start with / and don't start with //. Open redirects are a real attack vector, and the check is two lines:
const destination =
next && next.startsWith('/') && !next.startsWith('//')
? next
: '/dashboard'
The Two-Layer Security Model
Here is the part most authentication tutorials omit.
Proxy runs before a request reaches your pages and layouts. It blocks unauthenticated users from loading the dashboard. What it can't do is block unauthenticated users from calling your Server Actions directly.
Server Actions are public HTTP POST endpoints. An attacker who knows the action exists can call it directly with a crafted request, bypassing your pages entirely. The Next.js documentation is explicit about this [3]:
"Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path. Always verify authentication and authorization inside each Server Function rather than relying on Proxy alone."
This means your security model needs two layers:
- Proxy: Intercepts page navigation. Blocks unauthenticated users from seeing protected UI. Handles session refreshes. Good for UX and first-line defense.
- Server Actions: Verify identity and permissions on every operation. This check runs regardless of how the request arrived.
Don't skip layer two because layer one is in place. They protect different things.
Authorizing Server Actions
Every Server Action that reads user data or performs a privileged operation needs its own auth check. It's separate from the proxy check and it isn't optional.
The pattern: verify the session at the top of the action, before any database access.
// lib/supabase/server.ts
export async function getUser() {
const supabase = await createServerClientWithCookies()
const {
data: { user },
} = await supabase.auth.getUser()
return user
}
// actions/billing.ts
'use server'
import { getUser, createAdminClient } from '@/lib/supabase/server'
export async function getInvoices() {
const user = await getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const admin = createAdminClient()
const { data } = await admin
.from('purchases')
.select('*')
.eq('user_id', user.id)
return { data }
}
For admin actions, a reusable guard function eliminates the risk of forgetting the check in one place:
// actions/admin.ts
'use server'
import { getUser, createAdminClient } from '@/lib/supabase/server'
async function requireAdmin() {
const user = await getUser()
const adminEmails = process.env.ADMIN_EMAILS?.split(',') ?? []
if (!user || !adminEmails.includes(user.email!)) {
throw new Error('Unauthorized')
}
return user
}
export async function deleteUser(userId: string) {
await requireAdmin()
// Only reaches here if the caller is a verified admin
const admin = createAdminClient()
await admin.auth.admin.deleteUser(userId)
}
requireAdmin() throws before any database call runs. This prevents both horizontal privilege escalation (user A accessing user B's data) and vertical privilege escalation (a regular user calling an admin function). Throw rather than return an error object: it terminates execution immediately and can't be accidentally ignored by the caller.
For a deeper look at the Supabase session setup, client configuration, and server-side cookie patterns, see Supabase Authentication in Next.js App Router: The Complete 2026 Guide.
What proxy.ts Is Actually For
The rename from middleware to proxy signals something real about how this feature should be used. It's a network boundary, not an authorization system. It belongs at the edges of your route graph, not inside your data logic.
For most SaaS applications, the right structure is three layers:
- proxy.ts: Redirect unauthenticated users away from protected pages. Refresh session cookies. Block auth routes for logged-in users.
- Server Actions: Verify identity and permissions on every operation. Never assume the proxy handled it.
- Supabase RLS with default-deny: Keep tables locked to the anon key so even a misconfigured Server Action can't leak data. This is the last line of defense.
All three layers are independent. The proxy getting renamed doesn't affect the other two. The security model is the same regardless of what the file is called.
SecureStartKit ships with all three pre-configured: proxy.ts for route protection, getUser() checks in every Server Action, and RLS enabled on every table with no anon policies. The complete list of hardening steps is in The Next.js Security Hardening Checklist: 12 Steps to Ship a Secure App.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- proxy.js | Next.js— nextjs.org
- Getting Started: Proxy | Next.js— nextjs.org
- How to implement authentication in Next.js— nextjs.org
Related Posts
Supabase Authentication in Next.js App Router: The Complete 2026 Guide
A production-ready guide to implementing secure, server-side authentication with Supabase and Next.js App Router, moving beyond outdated client-side patterns.
How to Send Emails in Next.js with React Email and Resend (2026 Guide)
Stop writing HTML strings for emails. Learn how to build type-safe, component-based email workflows in Next.js using Resend and React Email.
How to Add Stripe Payments to Next.js Using Server Actions (2026 Guide)
A production-ready guide to integrating Stripe one-time payments in Next.js 15 with Server Actions, Zod validation, webhooks, and automated email delivery.