Better Auth on Next.js 16: the three setup gotchas that bite
Better Auth wires up fast, but Next.js 16 adds traps: an optimistic-only proxy check, the camelCase tables it owns, and plugin order that must be right.
Better Auth is genuinely quick to stand up — an instance, a Drizzle adapter, email and password, done. The friction is not the setup; it is three things specific to Next.js 16 that do not announce themselves, and each one fails in a way that looks like something else. Here they are, from the kit's actual auth wiring.
The proxy can only do an optimistic check
Next.js 16 renamed middleware to the "proxy," and it forbids slow work there — no database-backed session validation. So the proxy does the one cheap thing it is allowed to: look for the session cookie and bounce anyone without it to sign-in. It does not, and cannot, verify that the cookie is valid.
export function proxy(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
const signIn = new URL("/sign-in", request.url);
signIn.searchParams.set("redirect", request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
The gotcha is treating that as real authentication. A cookie can be present but expired, revoked, or forged; the optimistic check is a cheap gate to keep logged-out users out of the UI, not a security boundary. The real validation — auth.api.getSession — has to run in the page or route handler that actually serves protected data. Lean on the proxy as your only check and you have shipped a hole.
Better Auth owns its tables, in camelCase
Point the Drizzle adapter at the four tables Better Auth manages and let it own them:
export const auth = betterAuth({
database: drizzleAdapter(db(), {
provider: "pg",
schema: { user, session, account, verification },
}),
emailAndPassword: { enabled: true },
plugins: [nextCookies()], // must be last
});
Those tables use camelCase columns because that is what Better Auth's own CLI generates and expects. If the rest of your schema is snake_case — as a Postgres schema usually should be — do not "fix" the auth tables to match. Keep the two conventions side by side; renaming Better Auth's columns to fit your house style is how you break the adapter in a way that surfaces as mysterious null sessions later.
Plugin order: nextCookies() goes last
The nextCookies() plugin is what lets server actions and route handlers actually set the auth cookie on their response. It has to be the last plugin in the array, because plugins wrap the handler in order and the cookie writer needs to see the final response. Put it earlier and sign-in appears to succeed but the cookie never lands — so the next request looks logged-out, and you go hunting in the wrong place.
Things that bite
- The dev secret is a trap for prod. Without
BETTER_AUTH_SECRET, the kit falls back to a generated secret with a warning sonext buildworks unconfigured. Ship that and every deploy or new instance invalidates existing sessions. Set a real secret before production. - Set BETTER_AUTH_URL. Without it the origin is derived from the incoming request, and callbacks and redirects misfire behind a proxy or custom domain.
- The library moves fast — pin and verify. Export paths shift between versions (the cookies helper import is one that has moved). Pin the version and check the symbol in
node_modules/better-authbefore an upgrade, rather than trusting a tutorial's import path. - Social login is additive. Adding Google or GitHub is a
socialProvidersblock plus OAuth credentials in env — not a set of hand-rolled callback routes.
Auth is the identity everything else keys off — the per-user metering and quotas all resolve from the Better Auth user id. Shipwright, the Next.js and Claude starter kit this blog documents, ships this wiring intact so the identity layer is one less thing to get subtly wrong. See the authed surface in the live demo.