Comprehensive guide for implementing Next.js 16 proxy pattern for authentication and route protection...
This skill covers the new proxy pattern introduced in Next.js 16, which replaces the legacy middleware.ts approach. The proxy is optimized for Vercel's serverless environment and provides cleaner semantics.
// middleware.ts - Legacy (deprecated)
import { NextResponse } from "next/server";
export function middleware(request) {
// Logic here
}
export const config = { matcher: [...] };
// proxy.ts - New Convention (recommended)
import { NextResponse } from "next/server";
export async function proxy(request) {
// Logic here
}
export const config = { matcher: [...] };
Key differences:
middleware() → proxy()The proxy.ts file implements authentication-based route protection:
Protected Routes:
/diary - User personal diary/collections - User quote collections/episodes - Episode ratings and notes (list + detail pages)/characters - Comments and follows (list + detail pages)Accessible without authentication:
/ - Home page/guide - Public guide/login - Sign-in page/register - Sign-up page/api - API routes (handled separately)✅ CORRECT - Protects both variants:
const protectedPaths = [
"/diary",
"/collections",
"/episodes", // Protects /episodes (list)
"/episodes/", // Protects /episodes/ (with trailing)
"/characters", // Protects /characters (list)
"/characters/", // Protects /characters/ (with trailing)
];
❌ WRONG - Only protects with trailing slash:
const protectedPaths = [
"/episodes/", // Misses: /episodes
"/characters/", // Misses: /characters
];
Why both matter:
/episodes and /episodes/ as potentially differentThe config.matcher pattern determines which routes the proxy runs on:
export const config = {
matcher: [
// Match all routes EXCEPT:
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
What this excludes:
/api/* - API routes handled separately/_next/static/* - Static assets/_next/image/* - Image optimization/favicon.ico - FaviconWhat this includes:
/ and all dynamic routes/login and /register/diary, /episodes, /characters, etc.export async function proxy(request: NextRequest) {
// Validate session using Better Auth
const session = await auth.api.getSession({
headers: request.headers,
});
const isAuthenticated = !!session?.user;
const { pathname } = request.nextUrl;
// ... route protection logic
}
Key points:
session?.userpathname from request.nextUrl (not request.url)const protectedPaths = ["/diary", "/collections", "/episodes", "/characters"];
const isProtectedRoute = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
Important:
pathname.startsWith() for prefix matching/episodes not just /episodes/)callbackUrl for post-login redirectAfter redirecting to login, the login page must use the callbackUrl:
// app/login/page.tsx
import { useSearchParams, useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const handleSignIn = async (email, password) => {
await authClient.signIn.email({
email,
password,
onSuccess: () => {
// Redirect to original page or home
router.push(callbackUrl);
},
});
};
return (
// Login form...
);
}
// Prevent authenticated users from visiting auth pages
if (isAuthenticated && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
const protectedPaths = ["/admin", "/dashboard"];
const isProtected = protectedPaths.includes(pathname);
if (isProtected && !isAuthenticated) {
return NextResponse.redirect(new URL("/login", request.url));
}
const adminPaths = ["/admin"];
const isAdmin = session?.user?.role === "admin";
if (adminPaths.some((p) => pathname.startsWith(p)) && !isAdmin) {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
Some routes should work with OR without authentication:
// e.g., Character detail page can show comments if authenticated
const publicButEnhanced = ["/characters", "/episodes"];
const isPublicRoute = publicButEnhanced.some((p) => pathname.startsWith(p));
if (isPublicRoute) {
// Allow access but provide user data in response headers if authenticated
if (isAuthenticated) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", session.user.id);
return NextResponse.next({ request: { headers: requestHeaders } });
}
}
const DEBUG = process.env.NODE_ENV === "development";
export async function proxy(request: NextRequest) {
if (DEBUG) {
console.log(`[PROXY] ${request.method} ${request.nextUrl.pathname}`);
console.log(`[AUTH] Authenticated: ${!!session?.user}`);
}
// ... rest of logic
}
# Without authentication
curl -i http://localhost:3000/diary
# Should redirect to login with callbackUrl parameter
# Location: http://localhost:3000/login?callbackUrl=/diary
# With session cookie
curl -i -b "session=..." http://localhost:3000/diary
# Should return 200
Each proxy invocation validates the session:
const session = await auth.api.getSession({ headers: request.headers });
Optimization strategies:
Cache session in headers (already done in Better Auth)
// Better Auth caches in JWT
// Minimal overhead per request
Skip validation for public routes
const publicRoutes = ["/", "/api", "/_next"];
if (publicRoutes.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
Use regional compute (handled by Vercel)
// ✅ CORRECT
const isAuthenticated = !!session?.user && session.user.id;
// ❌ WRONG
const isAuthenticated = !!session; // Doesn't check user
if (isAuthenticated) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", session.user.id);
return NextResponse.next({ request: { headers: requestHeaders } });
}
// ❌ DON'T DO THIS
console.log("Session:", session); // Could expose tokens
// ✅ DO THIS
console.log("Authenticated user:", session?.user?.id);
const callbackUrl = searchParams.get("callbackUrl");
// ❌ WRONG - Open redirect vulnerability
router.push(callbackUrl);
// ✅ CORRECT - Whitelist or validate
const validRedirects = ["/", "/diary", "/collections"];
router.push(validRedirects.includes(callbackUrl) ? callbackUrl : "/");
// BEFORE
export function middleware(request) {}
// AFTER
export async function proxy(request) {}
mv middleware.ts proxy.ts
Most cases don't need imports to change - they're framework-level.
// This stays the same
export const config = {
matcher: [
// ...
],
};
# Run locally
pnpm dev
# Test protected route
curl http://localhost:3000/diary
# Should redirect to login
# Test public route
curl http://localhost:3000/
# Should return 200
# 1. Test redirect to login
curl -i http://localhost:3000/diary
# Expect: 307 redirect to /login?callbackUrl=/diary
# 2. Test public route still works
curl -i http://localhost:3000/
# Expect: 200
# 3. Test authenticated access
# First login in browser, then:
curl -i -b "cookie: ..." http://localhost:3000/diary
# Expect: 200 (or redirect to login page content)
import { test, expect } from "@playwright/test";
test("protected routes redirect to login", async ({ page }) => {
// Try to access protected route without auth
await page.goto("/diary");
// Should redirect to login with callbackUrl
expect(page.url()).toContain("/login");
expect(page.url()).toContain("callbackUrl=%2Fdiary");
});
test("authenticated users can access protected routes", async ({
page,
context,
}) => {
// Login first (set session cookie)
// Then access protected route
await context.addCookies([
{
name: "session",
value: "...", // valid session token
domain: "localhost",
path: "/",
},
]);
await page.goto("/diary");
// Should show diary page, not login
expect(page.url()).toBe("http://localhost:3000/diary");
});
Cause: Matcher pattern doesn't include the route
Solution:
// Check that route is in matcher and not excluded
export const config = {
matcher: [
// Should match your protected routes
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
Cause: Session not properly passed in headers
Solution:
// Ensure Better Auth is properly configured
const session = await auth.api.getSession({
headers: request.headers, // Must pass request headers
});
Cause: Login page also protected or callbackUrl not handled
Solution:
// Exclude login from protection
if (pathname === "/login" || pathname === "/register") {
return NextResponse.next();
}
// Handle callbackUrl in login page
const callbackUrl = searchParams.get("callbackUrl") || "/";
// Use it after successful auth
// proxy.ts
import { NextResponse, NextRequest } from "next/server";
/**
* Función proxy - debe exportarse como default o named export "proxy"
*/
export async function proxy(request: NextRequest) {
// Lógica de proxy
return NextResponse.next();
}
/**
* Config opcional - define rutas donde se ejecuta el proxy
*/
export const config = {
matcher: ["/about/:path*", "/dashboard/:path*"],
};
export const config = {
matcher: "/about",
};
export const config = {
matcher: ["/about", "/contact", "/dashboard/:path*"],
};
export const config = {
matcher: [
/*
* Match all request paths except:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico, sitemap.xml, robots.txt (metadata)
*/
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
export const config = {
matcher: [
{
source: "/api/:path*",
locale: false, // Ignora locale-based routing
has: [
{ type: "header", key: "Authorization", value: "Bearer Token" },
{ type: "query", key: "userId", value: "123" },
],
missing: [{ type: "cookie", key: "session", value: "active" }],
},
],
};
//about/:path (exacto) vs /about/:path* (wildcard)* = cero o más? = cero o uno+ = uno o más/about/(.*) es igual a /about/:path*import { NextResponse, NextRequest } from "next/server";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
const isAuthenticated = !!session?.user;
const { pathname } = request.nextUrl;
// Rutas protegidas
const protectedPaths = ["/dashboard", "/profile", "/admin"];
const isProtected = protectedPaths.some((path) => pathname.startsWith(path));
if (isProtected && !isAuthenticated) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/profile/:path*", "/admin/:path*"],
};
import { NextRequest, NextResponse } from "next/server";
const allowedOrigins = ["https://example.com", "https://app.example.com"];
const corsOptions = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export function proxy(request: NextRequest) {
const origin = request.headers.get("origin") ?? "";
const isAllowedOrigin = allowedOrigins.includes(origin);
// Handle preflight requests
if (request.method === "OPTIONS") {
const preflightHeaders = {
...(isAllowedOrigin && { "Access-Control-Allow-Origin": origin }),
...corsOptions,
};
return NextResponse.json({}, { headers: preflightHeaders });
}
// Handle simple requests
const response = NextResponse.next();
if (isAllowedOrigin) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
export const config = {
matcher: "/api/:path*",
};
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// A/B Testing con cookies
const bucket = request.cookies.get("bucket")?.value;
if (bucket === "b") {
return NextResponse.rewrite(new URL("/variant-b", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: "/product/:path*",
};
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// Leer cookies
const sessionCookie = request.cookies.get("session");
console.log(sessionCookie); // { name: 'session', value: 'abc123', Path: '/' }
// Verificar existencia
const hasSession = request.cookies.has("session");
// Crear respuesta con cookies
const response = NextResponse.next();
// Establecer cookies
response.cookies.set("theme", "dark");
response.cookies.set({
name: "user-id",
value: "123",
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict",
});
// Eliminar cookies
response.cookies.delete("old-cookie");
return response;
}
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// Clonar y modificar request headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-custom-header", "my-value");
requestHeaders.set("x-pathname", request.nextUrl.pathname);
// Pasar headers modificados a la app
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// Establecer response headers
response.headers.set("x-response-header", "value");
response.headers.set("Cache-Control", "public, max-age=3600");
return response;
}
import { NextRequest } from "next/server";
import { isAuthenticated } from "@/lib/auth";
export const config = {
matcher: "/api/:function*",
};
export function proxy(request: NextRequest) {
if (!isAuthenticated(request)) {
return Response.json(
{ success: false, message: "authentication failed" },
{ status: 401 }
);
}
// Continuar con la request
return NextResponse.next();
}
import { NextRequest, NextResponse } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"),
});
export async function proxy(request: NextRequest) {
const ip = request.ip ?? "127.0.0.1";
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
};
El proxy se invoca para cada ruta en tu proyecto. Orden de ejecución:
headers de next.config.jsredirects de next.config.jsrewrites, redirects, etc.)beforeFiles (rewrites) de next.config.jspublic/, _next/static/, pages/, app/)afterFiles (rewrites) de next.config.js/blog/[slug])fallback (rewrites) de next.config.jsruntime NO está disponible en archivos proxyDesactiva redirecciones automáticas de trailing slashes:
// next.config.js
module.exports = {
skipTrailingSlashRedirect: true,
};
// proxy.ts
const legacyPrefixes = ["/docs", "/blog"];
export default async function proxy(req) {
const { pathname } = req.nextUrl;
if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next();
}
// Aplicar manejo personalizado de trailing slash
if (!pathname.endsWith("/") && !pathname.match(/\.\w+$/)) {
return NextResponse.redirect(
new URL(`${req.nextUrl.pathname}/`, req.nextUrl)
);
}
}
Desactiva normalización de URL para control total:
// next.config.js
module.exports = {
skipMiddlewareUrlNormalize: true,
};
Next.js 15.1+ incluye utilidades de testing:
import { unstable_doesProxyMatch } from "next/experimental/testing/server";
// Verificar si proxy ejecuta en una URL
expect(
unstable_doesProxyMatch({
config,
nextConfig,
url: "/test",
})
).toEqual(false);
// Testear función completa
import { isRewrite, getRewrittenUrl } from "next/experimental/testing/server";
const request = new NextRequest("https://nextjs.org/docs");
const response = await proxy(request);
expect(isRewrite(response)).toEqual(true);
expect(getRewrittenUrl(response)).toEqual("https://other-domain.com/docs");
| Opción de Deploy | Soportado |
|---|---|
| Node.js server | ✅ Sí |
| Docker container | ✅ Sí |
| Static export | ❌ No |
| Adapters | Específico de plataforma |
Solución: Verificar matcher configuration
// Antes (incorrecto)
export const config = {
matcher: "about", // ❌ Falta el /
};
// Después (correcto)
export const config = {
matcher: "/about", // ✅ Empieza con /
};
Solución: Excluir rutas estáticas con negative lookahead
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Solución: Verificar que Better Auth/NextAuth obtiene headers correctamente
export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers, // ✅ Pasar headers
});
// Resto de la lógica
}
Esta skill está basada en Next.js 16.1.1 (Enero 2026).