BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide

Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide

Production-ready Neon Managed Better Auth on Vercel with Next.js: proxy /api/auth, service-account admin, six-digit…

27th September 2026·Updated on:30th September 2026·MŽMatija Žiberna·
Next.js
Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •How to Add "Log In As User" Impersonation with Neon Auth in Next.js
  • •How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js
  • •Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide
📄View markdown version
0

Frequently Asked Questions

Comments

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

How to Add "Log In As User" Impersonation with Neon Auth in Next.js
How to Add "Log In As User" Impersonation with Neon Auth in Next.js

29th September 2026

How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js
How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js

26th September 2026

Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide
Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide

3rd February 2026

Contents

  • What the quick start does not cover
  • Configure the Neon branch from the CLI and API
  • Environment variables on Vercel
  • Create the server and client instances
  • Proxy `/api/auth` and block the admin endpoints
  • Keep your own user table as the source of truth
  • Build your own sign-in form
  • Password reset with six-digit codes
  • Admin operations through a service account
  • Send auth emails through your own mailer
  • Testing webhooks when Vercel previews are protected
  • FAQ
  • Conclusion
On this page:
  • What the quick start does not cover
  • Configure the Neon branch from the CLI and API
  • Environment variables on Vercel
  • Create the server and client instances
  • Proxy `/api/auth` and block the admin endpoints
Build with Matija logotip

Build with Matija

Complex B2B websites, headless CMS platforms, AI workflows, and internal systems designed and built with Next.js and Payload CMS.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies
  • About Matija

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•Vse pravice pridržane•Politika zasebnosti•Pogoji uporabe
BuildWithMatija
Get In Touch

By Matija. Last updated: 25 September 2026. Tested with Next.js 16.3.6 (App Router), @neondatabase/auth 0.5.0-beta, neonctl 6.0.0, Prisma 7, Postgres 17, and Neon installed through the Vercel Marketplace.

Better Auth has rapidly become one of the most compelling modern authentication frameworks in the TypeScript ecosystem—modular, type-safe, and designed for full developer ownership over user tables. For teams running Next.js on Vercel paired with Neon Postgres, authentication previously leaned towards third-party managed providers like Stack Auth. However, as teams migrate to native solutions that live directly alongside their database, Neon Managed Better Auth has emerged as the premier choice.

Neon Managed Better Auth gives you a hosted Better Auth server that stores its data directly in your own Neon database, and on Vercel most of the platform plumbing is already automated for you. Whether you are migrating away from Stack Auth or starting fresh on a new stack, this guide teaches you exactly how to set up production authentication on your project.

A production setup comes down to six pieces: configure the Neon branch from the CLI and API, add four environment variables in Vercel, create one server auth instance and one client, proxy /api/auth through your app while blocking the admin endpoints, keep your own user table as the source of truth, and route auth emails through your own mailer with a signed webhook. This guide shows the setup I run in production for a multi-tenant Next.js app, including the parts the quick start skips: custom forms instead of <AuthView />, six-digit reset codes, admin operations that need a service account, and a beta client that throws where you expect it to return an error.

What the quick start does not cover

I set this up while moving a multi-tenant delivery-note SaaS onto Managed Better Auth. If you are coming from Stack Auth, the migration itself (backups, the provider lock, re-creating users) is covered in How to Migrate from Stack Auth to Neon Managed Better Auth in Next.js. The Neon quick start is fine for a demo. You mount a handler, drop in <AuthView /> and <UserButton />, and read useSession(). A real app needs a bit more than that. My app has organisations, roles and statuses that live in Prisma. Admins create accounts for their team members, reset passwords and delete users. The UI is in Slovenian, all form feedback uses toasts, and every email has to come from the product's own sender address.

None of that is exotic, but each piece has a Neon- or Vercel-specific detail that is easy to miss. It helps to know where everything lives before looking at code:

PieceWhere it livesWhat it does
Better Auth serverNeon (hosted, per branch)Sign-in, sessions, reset codes, admin endpoints
Auth tablesYour Neon database, neon_auth schemauser, account, session, verification, and a few more
Branch configurationNeon CLI and APISign-up policy, SMTP, trusted domains, webhooks, roles
Secrets and base URLVercel environment variablesBase URL, cookie secret, service-account credentials
Proxy route and session layerYour Next.js app/api/auth/[...path], getAuthenticatedUser, role checks
Organisation, role, statusYour own user table (Prisma)Source of truth, linked by authUserId

The rest of this guide goes through those pieces in the order you would set them up.

Configure the Neon branch from the CLI and API

Managed Better Auth is enabled per branch, and nearly all of its configuration can be scripted. That matters because you want preview, development and production branches set up identically, and a script is the only reliable way to get there. The Neon CLI's neon-auth command group covers most settings, and the REST API covers the rest. These are the operations I needed and how each one is done:

NeedAvailable via CLI/API?How
Enable Better Auth on a branchAPIPOST /projects/{id}/branches/{branch}/auth with {"auth_provider":"better_auth"}
Disable public sign-upCLI and APIneon neon-auth config email-password update --disable-sign-up
Custom SMTP senderCLIneon neon-auth config email-provider update --type standard --host … --port …
WebhooksCLI and APIPUT …/auth/webhooks
Trusted domainsCLIneon neon-auth domain add https://www.example.com
Create or delete a user, set a roleCLI and APIneon neon-auth user create, user set-role <id> --roles admin
Set a user's passwordNoBetter Auth /admin/set-user-password, needs an admin session
Change a user's email or nameNoBetter Auth /admin/update-user, needs an admin session

Enabling is a single API call. The CLI's enable command has no way to choose the provider, so a direct request is the reliable path:

bash
# File: scripts/neon-auth-setup.sh
curl -sS --fail-with-body -X POST "$API/branches/$BRANCH_ID/auth" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"auth_provider":"better_auth","database_name":"neondb"}'

The response includes the branch's base_url, which is the only URL your app needs. Two settings deserve a word. Add every origin your app is served from as a trusted domain (I added both https://www.example.com and the bare https://example.com), because Better Auth rejects requests whose Origin it does not recognise. And if users should not be able to register themselves, disable sign-up. Admin-created accounts and password reset keep working with sign-up disabled; I checked both on a test branch before relying on it.

I wrapped all of this in one idempotent shell script that takes --branch and --app-url arguments. Idempotent is the important word. When a step fails halfway, you want to fix the problem and rerun the script without creating duplicates or losing a generated password.

Environment variables on Vercel

If you installed Neon through the Vercel Marketplace, you already have some of this. After I enabled Better Auth on the production branch, the integration added NEON_AUTH_BASE_URL to the project's production environment by itself, marked as an integration-managed variable. Check that its value matches the base_url from the enable call before adding anything by hand. Mine matched, and trying to add it again gives an "already exists" error.

The remaining variables you add yourself. Pipe the value in on stdin so it never ends up in your shell history:

bash
# File: terminal
openssl rand -base64 32 | tr -d '\n' | vercel env add NEON_AUTH_COOKIE_SECRET production
printf '%s' "$SERVICE_EMAIL" | vercel env add NEON_AUTH_SERVICE_EMAIL production
printf '%s' "$SERVICE_PASSWORD" | vercel env add NEON_AUTH_SERVICE_PASSWORD production

The cookie secret signs the session cache cookie and must be at least 32 characters. The two service-account variables are explained in the admin section below. The app also needs to know its own public origin; I use the existing NEXT_PUBLIC_ADDRESS for that, both as the Origin header on server-side auth calls and for links in emails. For local development, vercel env pull .env.local brings everything down once the development environment has values too.

Because Managed Better Auth is tied directly to your Neon branches, running separate configurations across development, preview, and production environments is essential. If you haven't yet structured your environment variables and database branch isolation on Vercel, follow our Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide to establish that branching foundation before deploying authentication.

Create the server and client instances

With the branch configured and the variables in place, the app needs exactly one server instance and one client. On the server, createNeonAuth gives you session reads plus the handler you mount under /api/auth:

ts
// File: src/lib/auth/server.ts
import { createNeonAuth } from "@neondatabase/auth/next/server";

import { requireEnv } from "./env";

export const auth = createNeonAuth({
  baseUrl: requireEnv("NEON_AUTH_BASE_URL"),
  cookies: { secret: requireEnv("NEON_AUTH_COOKIE_SECRET") },
});

requireEnv is a three-line helper that throws a clear error when a variable is missing, which is much easier to debug than a request failing later with an undefined base URL. I deliberately left import "server-only" out of this file, because it is also imported from places that run outside the React Server Components environment. The client is even simpler:

ts
// File: src/lib/auth/client.ts
"use client";

import { createAuthClient } from "@neondatabase/auth/next";

export const authClient = createAuthClient();

It takes no arguments and talks to your own /api/auth route on the same origin. It already includes the plugins you need later, including email OTP for password reset, and it needs no provider component. I removed the old provider wrapper from the root layout entirely.

Proxy /api/auth and block the admin endpoints

The browser never talks to Neon directly. It talks to your app, and your app forwards the request to Neon. That is what auth.handler() does, and it only needs a catch-all route. I wrapped it so admin endpoints can never be reached from the browser through my domain:

ts
// File: src/app/api/auth/[...path]/route.ts
import { auth } from "@/lib/auth/server";

const handlers = auth.handler();

type RouteContext = { params: Promise<{ path: string[] }> };
type Handler = (request: Request, context: RouteContext) => Promise<Response>;

const blockAdminPaths =
  (handler: Handler): Handler =>
  async (request, context) => {
    const { path } = await context.params;
    if (path[0] === "admin") {
      return Response.json({ message: "Forbidden" }, { status: 403 });
    }
    return handler(request, context);
  };

export const GET = blockAdminPaths(handlers.GET);
export const POST = blockAdminPaths(handlers.POST);

The folder must be named [...path], because the handler reads params.path. The block is defence in depth: Neon already refuses admin calls from users without the admin role, but the admin endpoints have no legitimate caller in the browser, so there is no reason to forward them at all.

Keep your own user table as the source of truth

This is the decision that keeps the rest of the setup simple. Better Auth knows who someone is. Your database knows which organisation they belong to, what their role is and whether their account is active. I keep those apart and link them with one column, User.authUserId, which points at neon_auth.user.id.

The only function that talks to Better Auth about the current user is small, and it is cached per request so several server components can call it without repeating the lookup:

ts
// File: src/lib/auth/session.ts
export const getAuthenticatedUser = cache(
  async (): Promise<AuthenticatedUser | null> => {
    const { data } = await auth.getSession();
    const user = data?.user;
    if (!user) return null;
    return { id: user.id, email: user.email, name: user.name ?? null };
  },
);

Everything else builds on top of that. getCurrentAuthContext finds the Prisma user by authUserId. If there is none, it falls back to a matching email and relinks the row, and it activates invited users on their first sign-in:

ts
// File: src/lib/auth/provision.ts
const shouldActivate = existingByEmail.status === UserStatus.INVITE_PENDING;
const shouldRelink = existingByEmail.authUserId !== authUser.id;

That email fallback turned out to be useful in more than one way: it is what makes re-created auth accounts reconnect to existing app users on their own. Layouts then call requireAuthContext() and redirect by role, so drivers and admins end up in their own areas without any route-level middleware.

A related trap is Next.js proxy (middleware) placement. In Next.js 16, route-level request interceptors shifted with the middleware-to-proxy evolution (for a detailed breakdown of this transition and how file locations changed, see our guide on Why Next.js is Moving Away from Middleware: Understanding the proxy.ts Rename). If your app lives in src/app, Next.js only picks up src/proxy.ts. A proxy.ts at the repository root is silently ignored. The app I worked on had one there for months, and it had never run once. I deleted it and kept role enforcement in the layouts, which already did the job.

Build your own sign-in form

<AuthView /> is fine for English-language apps that accept its look. I wanted a form in the app's own design and language, with the same toast feedback as every other form, so I wrote a small one on top of authClient.signIn.email. The Better Auth client normally returns { data, error }, and the TypeScript types say the same. In practice, @neondatabase/auth 0.5.0-beta throws on a failed sign-in. My first version never reset its loading state after a wrong password; the button just sat there saying it was signing in. Handle both styles and the problem goes away:

ts
// File: src/app/prijava/_components/sign-in-form.tsx
try {
  const { error } = await authClient.signIn.email({ email, password });
  if (error) throw error;
} catch (error) {
  toast.error(getAuthErrorMessage(error, "Prijava ni uspela. Poskusite znova."));
  setIsPending(false);
  return;
}

toast.success("Prijava uspešna.");
router.refresh();
router.push("/admin");

The thrown value is a Supabase-style AuthError with a message such as "Invalid email or password" and a status, so getAuthErrorMessage maps known codes and known messages to translated text and falls back to a generic line. While testing this I also noticed every toast appeared twice. The root layout mounted a second toaster next to the provider that already rendered one. That had nothing to do with auth, but auth errors were where I first noticed it.

The sign-in page itself redirects users who are already signed in, but only when the full app context resolves. A signed-in user who has no app account, or whose account is inactive, would otherwise bounce between the login page and the dashboard forever, because the dashboard layout sends them back to login.

Password reset with six-digit codes

One of the great out-of-the-box benefits of Neon Managed Better Auth is that Neon can manage transactional emailing for you. You don't need to configure a custom SMTP server, set up Resend or Postmark API keys, or manage sender domains just to get verification emails and reset codes flowing during development and early production. If you want full custom domain branding and tailored HTML templates later, you can still switch to a custom SMTP server or signed webhooks at any time. But having zero-config, Neon-managed email delivery out of the box eliminates a major friction point when standing up auth.

Managed Better Auth defaults to one-time codes for password reset, not links, and that changes how you build the flow. The user asks for a code, receives six digits by email, and submits the code together with a new password. Both steps are client calls:

ts
// File: src/app/prijava/_components/password-reset-form.tsx
await authClient.forgetPassword.emailOtp({ email });

await authClient.emailOtp.resetPassword({ email, otp, password });

Two properties of the codes should shape your UI. A code expires after five minutes, and requesting a new code invalidates the previous one. I learned the first one when my own test code came back as OTP_EXPIRED. Both calls also throw like sign-in does, so they get the same try/catch treatment. My reset form has two steps on one page: an email step, and a code-plus-password step with a six-slot OTP input. A second route opens the form directly on the code step with the email filled in, which is where the button in the reset email points. The form states the five-minute limit and offers "send a new code", because otherwise "my code doesn't work" will be your most common support request.

Admin operations through a service account

If your admins manage other users, this section matters more than any other. Better Auth's admin endpoints only accept a signed-in session whose user has the Neon role admin. There is no server key that bypasses this.

Do not solve it by giving your app's admins that role. The Neon role is project-wide, not tenant-scoped. In a multi-tenant app, any organisation admin holding it could call Neon's admin API directly with their own session cookie and reset the password of a user in another organisation. I tested the opposite case to be sure: a normal user calling /admin/list-users gets YOU_ARE_NOT_ALLOWED_TO_LIST_USERS, and /admin/set-user-password is refused the same way. That is the behaviour you want for everyone except one identity.

That identity is a dedicated service account. The setup script creates it while sign-up is still open, gives it the role with neon neon-auth user set-role <id> --roles admin, and only then closes sign-up. A server-only module signs in as it and caches the resulting session cookie per server instance:

ts
// File: src/lib/auth/admin.ts
const response = await fetch(`${baseUrl}/sign-in/email`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Origin: origin },
  body: JSON.stringify({ email, password }),
  cache: "no-store",
});

const cookie = response.headers
  .getSetCookie()
  .map((header) => header.split(";")[0])
  .join("; ");

Every admin request then sends that cookie together with an Origin that is one of the trusted domains, and a 401 triggers one fresh sign-in and a retry. On top of that sit five small helpers: createAuthUser, setAuthUserPassword, updateAuthUser, removeAuthUser and findAuthUserByEmail. They are the only way the rest of the app touches user management in Better Auth. My server actions check the organisation and role in Prisma first, exactly as before, and only then call a helper. Authorisation stays in your code; the service account only carries it out. For extending this service account pattern to let superadmins troubleshoot issues, see our complete guide to adding "log in as user" impersonation with Neon Auth.

Send auth emails through your own mailer

By default Neon sends verification and reset emails from a shared sender with its own template. You can make them yours in two ways, and I use both.

Setting a custom SMTP provider on the branch (config email-provider update --type standard … with your SMTP credentials) makes Neon's default emails come from your own address. Subscribing to the send.otp webhook goes one step further: Neon skips its email entirely and calls your endpoint with the code, and you send whatever template you like. The SMTP setting then remains a fallback, because disabling the webhook brings Neon's own emails back, still from your sender.

The webhook is signed with a detached Ed25519 JWS over the timestamp and the raw request body, so read the body as text and verify it before parsing:

ts
// File: src/app/api/webhooks/neon-auth/route.ts
export async function POST(request: Request) {
  const rawBody = await request.text();

  try {
    await verifyNeonAuthWebhook(rawBody, request.headers);
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return Response.json({ message: "Invalid signature" }, { status: 401 });
    }
    throw error;
  }

  const event = JSON.parse(rawBody) as NeonAuthWebhookEvent;
  // event.event_data.otp_code, event.event_data.otp_type, event.user.email
}

The verification fetches the public key from ${NEON_AUTH_BASE_URL}/.well-known/jwks.json, picks the key named in the x-neon-signature-kid header, rebuilds the signing input as header.base64url(timestamp + "." + base64url(rawBody)), and rejects anything older than five minutes. After that, the route looks at otp_type. For forget-password it renders my reset template with the code and a link to the reset page and sends it through the app's existing mailer. It must answer within the timeout you configured (up to ten seconds, I use five), and Neon retries 5xx responses, so I keep a small set of already-delivered event ids to avoid sending the same email twice.

Emails that Neon does not send at all, such as onboarding messages with temporary passwords, go through the same mailer directly from the server actions that create users. One detail from my mailer is worth copying: it copies an admin address on outgoing mail by default, and for credential emails I explicitly pass empty cc and bcc so temporary passwords go to exactly one inbox.

Testing webhooks when Vercel previews are protected

This is the Vercel detail that caught me out. Neon has to reach your webhook URL from the internet. If your preview deployments sit behind Vercel's deployment protection, Neon gets redirected to Vercel's SSO login and your handler never runs. I checked an existing preview URL first: it answered with a 302 to vercel.com/sso-api.

Rather than change the project's protection settings, I tested against my local dev server through a temporary Cloudflare quick tunnel. I pointed the test branch's webhook at the tunnel URL, requested a reset code, and watched the real signed request arrive, pass verification and send the email in under a second. If you already use cloudflared with a named tunnel, pass an empty config file (cloudflared --config /path/to/empty.yml tunnel --url http://localhost:3000). Otherwise the ingress rules in your ~/.cloudflared/config.yml take over and answer every request with 404. In production the webhook points at your real domain, where there is no protection in the way.

FAQ

Do I need <AuthView /> and the auth UI package? No. The client from @neondatabase/auth/next already covers sign-in, sign-out and the OTP reset calls, so you can build your own forms and drop the UI package completely. Use <AuthView /> if its look and language fit your app.

Can my admins manage users without a service account? Only if you give them the Neon admin role, which is project-wide. In a single-tenant app that can be acceptable. In a multi-tenant app it lets one organisation's admin manage users of another, so use a service account and keep app roles in your database.

Why does my sign-in button stay stuck after a wrong password? @neondatabase/auth 0.5.0-beta throws on failed auth calls instead of returning { error }. Wrap each client call in try/catch and treat a thrown error and a returned error the same way.

Why do users say their reset code does not work? Codes expire after five minutes, and each new request invalidates the previous code. Say both things in the form and in the email.

Does disabling sign-up break password reset or admin-created accounts? No. I tested both with sign-up disabled: the service account can still create users, and existing users can still reset their passwords.

Conclusion

A production setup of Neon Managed Better Auth on Vercel comes down to a few clear boundaries. Neon hosts the auth server and keeps its tables in your database, configured per branch through a script. Vercel holds four variables, one of which the Marketplace integration adds for you. Your app mounts one proxy route that forwards everything except the admin endpoints, and keeps its own user table as the source of truth, linked by authUserId. On top of that come custom forms that handle a client that throws, a six-digit reset flow with honest expiry copy, a single service account for admin operations, and a signed webhook that routes auth emails through your own mailer.

If you are setting this up now, start with the setup script and the proxy route. The rest builds on those two pieces, and they are the parts where a small mistake is hardest to spot later.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

Comments