You move a session check into the proxy (Next.js 16's renamed middleware) so signed-out visitors never see a flash of the dashboard shell. Locally it looks fine. Deployed, every request to a protected route dies with something like:
Error: The edge runtime does not support Node.js 'net' module.
or
TypeError: Cannot read properties of undefined (reading 'connect')
or (the worst version, because it looks like a logic bug) every visitor is treated as signed out, including the ones holding a valid cookie.
What is actually happening
The proxy runs in the edge runtime. That runtime is not Node. It has fetch,
Web Crypto and the standard web APIs, and it has no net, no tls, no
node:crypto, no filesystem. It exists to be tiny and to start in single-digit
milliseconds at the CDN edge.
Better Auth's server instance needs the opposite. It reaches your database
through an ORM adapter, and every Postgres driver worth using opens a TCP
socket. Import auth into the proxy (directly, or transitively through a
helper that imports it) and the bundler pulls the whole adapter and driver into
an environment that cannot run them.
The transitive case is the one that bites. A file that only exports a small
isAdminPath() helper is safe until someone adds an import of
@/lib/auth/session to the top of it for an unrelated reason. Now the proxy
bundle contains the ORM.
The wrong fix
Two tempting non-answers:
// 1. force the proxy onto the Node runtime
export const config = { runtime: "nodejs" };
Support for a Node-runtime proxy varies by host and version, and even where it works you have signed up for a database round trip in front of every request, including static assets that slipped through your matcher. That is a latency tax on the whole site to save one render.
// 2. decode the cookie by hand at the edge
const payload = JSON.parse(atob(cookie.split(".")[1]));
if (payload.role === "admin") { /* ... */ }
This is worse than useless: it reads an unverified value out of a client-controlled string. Anyone can write that cookie. This is not an optimisation, it is an authorisation bypass.
The fix
Split the job in two: a cheap, fallible presence check at the edge, and the real check where the code can actually reach the database.
In the proxy: does a session cookie exist?
import { getSessionCookie } from "better-auth/cookies";
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const cookie = getSessionCookie(request);
if (!cookie && request.nextUrl.pathname.startsWith("/admin")) {
const url = request.nextUrl.clone();
url.pathname = "/sign-in";
url.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/dashboard/:path*", "/settings/:path*"],
};
getSessionCookie reads the cookie by name. It does not verify the signature,
does not know whether the session was revoked, and does not know the user's
role. It answers exactly one question: is it worth rendering this page at all?
In the page or layout: who is this, really?
import { requireRole } from "@/lib/auth/session";
export default async function AdminLayout({ children }: { children: ReactNode }) {
await requireRole("admin");
return <AdminShell>{children}</AdminShell>;
}
This runs on the Node runtime, verifies the signature, reads the session row (or the signed cookie cache), and knows about bans and revocations. It is the security boundary. The proxy is a redirect optimisation in front of it.
Why this split is not a compromise
An expired-but-present cookie still reaches the page, and the page correctly sends it to sign-in. A revoked session still reaches the page, and the page correctly rejects it. The only thing the edge check can get wrong is doing a render that turns into a redirect, which costs a few milliseconds and leaks nothing.
The inverse arrangement (trusting the edge, skipping the render check) gets the security wrong in exchange for the same few milliseconds.
Two more edge traps
The matcher that matches everything. A matcher like "/(.*)" runs the proxy
for _next/static chunks, images and fonts. At best you pay for a function
invocation per asset; at worst a redirect breaks CSS loading and the site
renders unstyled. Exclude Next internals and anything with a file extension.
The auth route. Never let the proxy protect /api/auth/**. The callback
that completes a sign-in arrives without a session by definition, so protecting
it means no one can ever sign in: a redirect loop with no error message.
Checking your work
bun run buildsucceeds. A proxy bundle that reached fornode:netfails here, not at runtime.- Sign out, request a protected page, and confirm the redirect happens before any HTML for the shell is sent.
- Edit the session cookie in devtools to a plausible-looking but invalid value. The proxy lets it through, the page rejects it, and you land on sign-in. That is the design working, not a bug.
- Delete the proxy file entirely and confirm the protected page is still protected. If it is not, the check was in the wrong place all along.