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
Step-by-step Next.js migration from Stack Auth to Neon Managed Better Auth with backup, admin, and user-relink…
·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, @neondatabase/auth 0.5.0-beta, neonctl 6.0.0, Prisma 7 and Postgres 17.
You cannot run Stack Auth and Neon Managed Better Auth side by side. Neon allows exactly one auth provider per project, and the moment you unlink Stack, the Stack keys Neon issued for you stop working. So this migration is a hard cutover. You back up and prove the backup restores, you rehearse on a Neon branch, and then in one short window you switch the provider, deploy the new SDK, re-create every user (password hashes cannot be moved), and relink them to your own user table. This guide walks through the exact migration I ran on a production Next.js app, including the parts the official migration guide does not mention: the provider lock, the leftover schema that blocks provisioning, admin operations that need a special session, a beta client that throws instead of returning errors, and a user migration script you can run twice without breaking anything.
Why the official migration guide is not enough
I recently moved a multi-tenant delivery-note SaaS off legacy Neon Auth. It had been running on Stack Auth (@stackframe/stack) since Neon launched its auth product, and Neon now points everyone to Managed Better Auth. The official "From Stack Auth (legacy)" guide is a good SDK reference: it shows StackServerApp becoming createNeonAuth, useUser becoming , becoming . What it does not tell you is what happens to your users, your admin tooling and your uptime.
Three facts shaped the whole migration once I found them. First, a Neon project can only have one auth provider, so you cannot enable Better Auth on a branch to test it while production keeps using Stack. Second, Neon states plainly that existing password users cannot be migrated because the hashing algorithms differ, so every user needs a new password. Third, Better Auth's admin endpoints (create user, set password, change email, delete) only work from a signed-in session whose user has the Neon role admin, which matters a lot if your app lets organisation admins manage their own users.
A short aside if you are still on Stack and considering the "eject" route instead: claiming your Stack project gives you the full Stack dashboard, and its email templates are addressed by UUID, not by the friendly names the docs suggest. That works, but you are then running auth on a platform Neon no longer develops. I chose to migrate.
In this app, the Prisma User table was already the source of truth for organisation, role and status, and auth only linked to it through a User.authUserId column. If your app is structured the same way, the migration is mostly about re-pointing that one column. Everything below assumes that shape.
Step 1: Back up, then prove the backup restores
Do this before you touch any auth setting, because unlinking Stack is not reversible from the Neon side. I made three independent backups and verified each one.
The fastest restore path is a Neon snapshot branch. It is an instant copy-on-write of the whole database, including the legacy neon_auth.users_sync table:
The second backup is an offline dump that survives even if something goes wrong with the Neon project itself. One gotcha here: my local pg_dump was version 16 and the server runs Postgres 17, and pg_dump refuses to dump a newer server. The official Docker image solves that without installing anything:
A backup you have never restored is a hope, not a backup. I restored the dump into a throwaway branch and compared both row counts and an MD5 hash of the table contents against production:
sql
-- File: verify-restore.sqlselect md5(string_agg(t::text, '|'orderby t.id)) from "User" t;
select md5(string_agg(t::text, '|'orderby t.id)) from "DeliveryNote" t;
Identical hashes on both sides mean the dump is complete. The third backup is a CSV mapping each Prisma user id to its Stack authUserId. If you ever need to roll back, that single file turns the database side of the rollback into one UPDATE. Finally, record the current production deployment URL (vercel ls --prod) and tag the current commit, so the code side of a rollback is vercel rollback <url>.
If you haven't yet established isolated development and preview database branches on Neon and Vercel, review our Vercel Neon Setup for Next.js: 3-Tier Enterprise Guide first. Having clean branch separation ensures your spike tests and production rehearsals never risk live customer records.
Step 2: Rehearse on a spike branch
Before writing any application code, I checked that every operation the app needs is actually possible, first through the CLI and API and then on a throwaway branch. The Neon CLI (neonctl) has a neon-auth command group that covers most of the configuration. I also checked the endpoints against the OpenAPI spec. This is what I found:
The last two rows are the important ones. Everything you configure once can be scripted, but the runtime operations your admin UI needs have to go through Better Auth's admin endpoints, and those need a signed-in admin. I cover how to handle that in the admin section below.
On the spike branch I then proved the full chain end to end: enabling Better Auth, creating a service account, creating a user with a password, setting a new password, changing an email, deleting the user, disabling sign-up, sending a reset code through my own SMTP, and completing a reset with that code. Only after every step worked did I write application code.
"Neon Auth with different auth provider already exists for this project"
This is the error you will get the first time you try to enable Better Auth on any branch of a project that still has Stack attached. It does not matter that you are on a spike branch: the provider is locked at project level.
The fix is to unlink Stack from the project:
bash
# File: terminal
neon neon-auth disable --project-id "$NEON_PROJECT_ID" --branch main
Leave out --delete-data; it defaults to false, and your neon_auth.users_sync rows stay in place. What the docs do not warn you about is the side effect. The Stack publishable and server keys that Neon issued are revoked immediately, and your production login breaks at that moment. Right after unlinking, a direct call to the Stack API returned this:
text
INVALID_PUBLISHABLE_CLIENT_KEY: The publishable key is not valid for the project "…". Does the project and/or the key exist?
This is why the order of the cutover matters so much, and I come back to it at the end. Plan for this step to be the start of your downtime window, not something you do on a quiet afternoon days before the deploy.
"The neon_auth schema already exists and cannot be automatically provisioned"
With Stack unlinked, the next attempt to enable Better Auth fails again, this time because the old Stack sync table still lives in the neon_auth schema and Better Auth wants that schema name for its own tables. The error text suggests dropping it. I renamed it instead, so the data stays available until I am sure I will not need it:
sql
-- File: rename-legacy-schema.sqlALTER SCHEMA neon_auth RENAME TO neon_auth_stack_legacy;
Then enable Better Auth through the API. The CLI's enable command has no way to choose the provider, so a direct request is the reliable path:
The response contains your new base_url. Better Auth now creates its tables (user, account, session, verification and a few more) in your own database under , which means you can join against them with plain SQL. If your project was installed through the Vercel Marketplace, the integration also adds to your production environment variables automatically. Check that it matches before you add your own.
I wrapped all of the branch configuration (schema rename, enable, trusted domains, SMTP, service account, disabling sign-up and webhook) into one idempotent shell script. Being able to rerun it is not a luxury, as you will see in the gotchas below.
Swap the server and client SDK
The code side is the part the official guide covers well, so I will focus on what I did differently. On the server, one createNeonAuth instance gives you session reads and the API proxy handler:
The cookie secret must be at least 32 characters; openssl rand -base64 32 is fine. On the client, createAuthClient() from @neondatabase/auth/next takes no arguments and talks to your own /api/auth route. It already includes the email-OTP plugin, which matters for password reset later.
The proxy route mounts Neon's handler. I wrapped it so the browser can never reach the admin endpoints through my domain, because only the server should ever call those:
The session layer is where keeping your own user table pays off. My existing getCurrentAuthContext and requireAuthContext helpers stayed exactly the same. Only the function that asks "who is signed in?" changed:
Every layout and server action kept calling requireAuthContext() as before. The layouts already enforce roles and redirect drivers and admins to their own areas, so no route guards had to change. While doing this I also found that the app's root-level proxy.ts had never run at all: with the app living in src/app, Next.js only picks up src/proxy.ts. I deleted it rather than switch it on halfway through a migration.
Sign-in and password reset forms
I replaced Stack's <SignIn /> with a small form of my own rather than <AuthView />, because the app is in Slovenian and uses Sonner toasts for all feedback. Better Auth's client normally returns { data, error }, and the Neon types say the same. In practice, @neondatabase/auth 0.5.0-beta throws on a failed sign-in, so my first version left the button stuck on its loading label after a wrong password. The fix is to handle both styles:
The thrown value is a Supabase-style AuthError with a message such as "Invalid email or password", so my message helper maps both code values and known messages to translated text.
Password reset in Managed Better Auth uses one-time codes by default, not links. The user asks for a code with authClient.forgetPassword.emailOtp({ email }), receives six digits, and submits them with authClient.emailOtp.resetPassword({ email, otp, password }). The code expires after five minutes, and requesting a new code invalidates the previous one. I found that out the practical way: the first code I tried to use had expired and came back as OTP_EXPIRED. Put the five-minute limit in the form and in the email copy, because "my code doesn't work" will otherwise become a support ticket.
Admin operations need a service account
This is the design decision I would most want someone to tell me before a migration like this. My admins create users, reset passwords and delete accounts from the app. With Stack, the server secret key allowed all of that. With Better Auth, those endpoints only accept a signed-in session whose user has the Neon role admin.
The tempting fix is to give your organisation admins that role. Do not do that in a multi-tenant app. The Neon role applies to the whole project, not to one tenant, so any organisation admin could call Neon's admin API directly with their own session cookie and reset the password of a user in another organisation. I checked the opposite case on the spike branch too: a normal user calling /admin/list-users gets YOU_ARE_NOT_ALLOWED_TO_LIST_USERS, which is exactly what you want for everyone except one identity.
That one identity is a dedicated service account, created once by the setup script and given the admin role with neon neon-auth user set-role <id> --roles admin. A server-only module signs in as it, caches the session cookie per server instance, and exposes the handful of operations the app needs:
Inside adminFetch, every request carries the cached cookie and an Origin header that matches one of the project's trusted domains, and a 401 triggers one fresh sign-in and a retry. Your app roles stay in your own database, where your tenancy checks already are. The full reasoning, and the pitfalls around it, deserve their own article, so here I only cover what the migration needs.
Send auth emails through your own mailer
Neon sends verification and reset emails from a shared sender by default. You have two ways to make them yours, and I used both. Setting a custom SMTP provider on the branch makes Neon's default emails come from your own sender address. Subscribing to the send.otp webhook goes further: Neon then skips its own email and calls your endpoint with the code, and you send your own template.
The webhook is signed with a detached Ed25519 JWS. The signature covers the timestamp and the raw request body, so you have to verify against the body exactly as received, before any JSON parsing:
The public key comes from ${NEON_AUTH_BASE_URL}/.well-known/jwks.json, matched by the x-neon-signature-kid header, and I reject timestamps older than five minutes. The route reads event_data.otp_code and event_data.otp_type. For forget-password it sends my reset template with the code and a link to the reset page. Drivers with generated alias addresses have no real inbox, so for them the route returns 200 without sending anything; their admin resets their password instead.
Onboarding emails with temporary passwords are not something Neon sends at all, so those moved from Stack's templates to the app's existing Brevo mailer. One small but important detail: my mailer copies an admin address on every outgoing email by default, which is fine for delivery notes but not for temporary passwords. The credentials helper passes empty cc and bcc on purpose.
Migrate the users
Because password hashes cannot move, "migrating users" means creating a fresh Better Auth account for each existing user and pointing User.authUserId at it. I wrote a script that is a dry run by default and only writes with --apply, and that I could safely run more than once:
Users with real email addresses get a long random password that nobody knows. With --send-notices, they also receive an email explaining that sign-in was renewed and that they should set a new password through "forgot password". Users with generated alias emails get a temporary password written to a local file with mode 600, for their admin to hand over. Users who are inactive get an account but no notice, because the email would invite them to a sign-in the app then rejects.
The script uses the same admin.ts and mailer modules as the app, and those import server-only. Run it with npx tsx --conditions react-server, which resolves server-only to its empty build outside Next.js. You also need server-only installed as a real dependency, because plain Node cannot see the copy Next.js bundles internally.
On the spike branch, the first apply created 13 accounts and relinked one, and the second run reported all 14 as "already migrated". That second run is the real test: during a cutover you want to be able to rerun anything that fails halfway.
Cutover order that keeps downtime short
My real migration had a longer login outage than it needed to, because I unlinked Stack during the rehearsal, before the new code was ready. The keys died immediately and production stayed down until the deploy. Knowing that, this is the order I would use, with every step already rehearsed:
Have the new code finished and reviewed on a branch, ready to merge. On Vercel, merging to main deploys, so the merge itself is step 5.
Take fresh backups: a new snapshot branch, a new dump and a new mapping CSV.
Unlink Stack. Downtime starts here.
Rename the neon_auth schema and run the setup script on the production branch.
Steps 3 to 5 take minutes when they are scripted. After the deploy, I verified the live site from the outside: the new sign-in form is served, /api/auth/admin/* returns 403, an unsigned webhook gets 401, a real reset code arrives through the webhook with a 200, and every user row is linked to an auth user. Within minutes of the notices going out, the logs showed a real user requesting a reset code.
Keep the legacy data and the Stack environment variables for a week or two before cleaning up. Then drop neon_auth_stack_legacy, delete the snapshot branches, and remove the old variables.
FAQ
Can I keep Stack Auth running while I test Better Auth?
Not in the same Neon project. The provider is locked per project, and enabling Better Auth on any branch fails while Stack is attached. Rehearse everything you can with the CLI and API first, and treat the unlink as the start of your cutover window.
Do users keep their passwords?
No. Neon states that the hashing is incompatible, so every user gets a new account and sets a new password. If your own user table is the source of truth, their data, roles and history are unaffected; only the credentials change.
Why not just give my admins the Neon admin role?
Because it applies to the whole project. In a multi-tenant app, an organisation admin with that role could reset passwords of users in other organisations by calling Neon directly. Give the role to one server-side service account and keep app roles in your database.
Why does my sign-in button get stuck after a wrong password?@neondatabase/auth 0.5.0-beta throws on failed sign-in instead of returning { error }. Wrap every auth client call in try/catch and treat a returned error and a thrown error the same way.
How do I roll back if the cutover fails?
Roll back the deployment to the one you recorded, and restore authUserId from the mapping CSV or restore the whole branch from the snapshot. Because unlinking revoked the Neon-issued Stack keys, a rollback to Stack also needs fresh keys from a claimed Stack project, so decide in advance whether rollback means going back to Stack or fixing forward.
Conclusion
Migrating from Stack Auth to Neon Managed Better Auth is less about swapping SDK calls and more about sequencing. Neon allows one auth provider per project, so there is no side-by-side phase. Unlinking Stack revokes its keys on the spot, and the old neon_auth schema has to be moved out of the way before Better Auth can provision. Password hashes do not migrate, so users are re-created and relinked to your own table. And the admin endpoints need a signed-in admin session, which in a multi-tenant app means a service account rather than handing out a project-wide role.
The approach that made this safe was boring on purpose: verified backups, a spike branch where every capability was proven before any code was written, an idempotent setup script, a migration script that is a dry run by default, and a cutover short enough to fit into a few minutes of scripted steps. If you are about to do the same migration, start with the backup section and do not skip the restore test.
Once you have completed the cutover and relinked your users to the newly provisioned Better Auth schema, you will need the day-to-day production machinery to run the application long-term—including proxying /api/auth while blocking admin routes, handling six-digit reset codes, and routing auth emails through signed webhooks. See Neon Managed Better Auth in Next.js on Vercel: A Production Setup Guide for the complete multi-tenant production architecture.
Let me know in the comments if you have questions, and subscribe for more practical development guides.