Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide | Build with Matija
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…
·Updated on:··
⚡ 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.
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.
Neon Managed Better Auth gives you a hosted Better Auth server that stores its data in your own Neon database, and on Vercel most of the wiring is already done for you. 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:
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:
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:
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:
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:
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:
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:
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:
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:
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. 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:
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
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:
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:
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 . 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.
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:
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
Piece
Where it lives
What it does
Better Auth server
Neon (hosted, per branch)
Sign-in, sessions, reset codes, admin endpoints
Auth tables
Your Neon database, neon_auth schema
user, account, session, verification, and a few more