Deployment
Put the Next.js + Neon starter in production: a Neon database, migrations, two Vercel projects for the web and admin apps, and post-deploy checks.
The demo runs the website and the admin app on Vercel as two projects, and the database on Neon. This page covers what is specific to this kit. Platform steps that are the same for every Node-hosted starter are in the Vercel deployment guide. The repository's own notes on the admin deployment are in docs/admin-app.md.
Migrations run before the deploy, never at startup
Neither app applies migrations when it boots. You run pnpm db:migrate against the direct endpoint yourself, then deploy. The mcp_records migration must be in place before either app serves OAuth connections.
Create the Neon database
Create a Neon project. The README asks for two connection strings:
DATABASE_URL: the pooled endpoint, used by both apps at runtime, ideally with an application-only role.DATABASE_DIRECT_URL: the direct (non-pooled) endpoint, used only for migrations, ideally with a role that owns the schema.
If you create a role named app_runtime for the apps, migration 0003 grants it access to mcp_records and adds a row-level policy for it. If that role doesn't exist, the migration skips that block and the table owner is used. Fresh installs don't need any extra roles.
Keep Neon's default of suspending compute after five idle minutes. The kit avoids always-on pollers for that reason.
Apply the migrations
From your machine or a CI job with both URLs set:
DATABASE_URL="<pooled url>" DATABASE_DIRECT_URL="<direct url>" pnpm db:migrateapps/database/src/migrate.ts opens a single connection to DATABASE_DIRECT_URL and applies apps/database/drizzle-postgres/. Run it again for every release that adds a migration, before the new builds go live. Don't run the seeds against production; they create demo accounts with a known password.
The optional PostgreSQL schema validation workflow in .github/workflows/postgres-schema.yml replays the migrations on a clean container when you trigger it by hand.
Prepare email and storage
Email: set EMAIL_PROVIDER=resend, RESEND_API_KEY and a MAIL_FROM on a domain you have verified in Resend. See emails with Resend. Verification emails must work before anyone can finish signing up.
Storage: create two buckets on an S3-compatible service and set the S3_* variables on both apps. MinIO is only for local development. apps/web/next.config.ts derives a next/image remotePatterns entry from S3_ENDPOINT, so uploaded images render from your bucket without editing the config; the admin app's next.config.ts allows any HTTPS image host.
Create two Vercel projects
Import the same repository into Vercel twice. The repository has no vercel.json; docs/admin-app.md records the settings the demo uses:
| Project | Root directory | Framework | Demo hostname |
|---|---|---|---|
| Web | apps/web | Next.js | nextjs-neon.usenextbase.com |
| Admin | apps/admin | Next.js | nextjs-neon-admin.usenextbase.com |
Each project deploys independently from the same commits. Vercel picks pnpm from the root packageManager field; the equivalent local commands are pnpm --filter web build and pnpm --filter admin build. Both apps need Node 24.
Add the production environment variables to each project. Use the exact names from the environment variables page.
Required on the web project:
| Variable | Value |
|---|---|
DATABASE_URL | Neon pooled connection string |
BETTER_AUTH_URL | The web origin, for example https://app.example.com |
NEXT_PUBLIC_APP_URL | The same web origin |
NEXT_PUBLIC_WEB_APP_URL | The same web origin |
NEXT_PUBLIC_ADMIN_APP_URL | The admin origin, for example https://admin.example.com |
BETTER_AUTH_SECRET | A new random string, 32 characters or more; the same value on both projects |
REVALIDATE_SECRET | A random string; the same value on both projects |
ENFORCE_PRODUCTION_SECRETS | true |
EMAIL_PROVIDER, RESEND_API_KEY, MAIL_FROM | Your Resend setup |
S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_PUBLIC, S3_BUCKET_PRIVATE, S3_FORCE_PATH_STYLE | Your bucket |
Required on the admin project:
| Variable | Value |
|---|---|
DATABASE_URL | The same Neon pooled connection string |
BETTER_AUTH_URL | The admin origin |
NEXT_PUBLIC_APP_URL | The same admin origin |
NEXT_PUBLIC_SITE_URL | The same admin origin |
NEXT_PUBLIC_ADMIN_APP_URL | The same admin origin |
NEXT_PUBLIC_WEB_APP_URL | The web origin |
BETTER_AUTH_SECRET | The same value as the web project |
REVALIDATE_SECRET | The same value as the web project |
ENFORCE_PRODUCTION_SECRETS | true |
S3_* | The same bucket as the web project, for marketing CMS uploads |
Optional on either project:
BETTER_AUTH_TRUSTED_ORIGINSfor preview domains that should be able to sign in.VALKEY_URLif you run Valkey or Redis. Without it the kit uses Postgres for the auth cache, rate limits and locks, which works on Neon.- Realtime on the web project:
NEXT_PUBLIC_CENTRIFUGO_URLas awss://URL, plusCENTRIFUGO_URL,CENTRIFUGO_API_URL,CENTRIFUGO_API_KEYandCENTRIFUGO_TOKEN_HMAC_SECRET. LeaveNEXT_PUBLIC_CENTRIFUGO_URLunset to disable browser realtime; the app works without it. - Stripe:
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRETand the price IDs on the web project, with a webhook pointing at/api/stripe/webhookson the web origin. Give the admin projectSTRIPE_SECRET_KEYtoo if you want the Sync products action on the payment gateway page to pull the Stripe catalog. OPENAI_API_KEY, PostHog, Google Analytics, Sentry and Vercel Analytics as needed.
DATABASE_DIRECT_URL is not needed by either app process. Keep it in the place you run migrations from.
Auth callbacks and origins
Better Auth builds its URLs from BETTER_AUTH_URL, and the trusted origins are BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL and BETTER_AUTH_TRUSTED_ORIGINS. On each project all of these must agree with the domain users actually open on that app. A preview deployment with a different origin needs its own values or an entry in BETTER_AUTH_TRUSTED_ORIGINS.
Sessions are host-only cookies. Administrators sign in on the admin host at /login; a web session does not carry over. Impersonation crosses the boundary with a one-minute, single-use token that the admin app sends to <NEXT_PUBLIC_WEB_APP_URL>/auth/impersonation and the web app redeems at /api/impersonation, so NEXT_PUBLIC_WEB_APP_URL on admin and NEXT_PUBLIC_ADMIN_APP_URL on web must both be right or the handoff and the return trip fail.
Email links (verification, reset, invitations) are built from the web app's NEXT_PUBLIC_APP_URL. If you enable a social provider, register the callback for your web BETTER_AUTH_URL origin with that provider.
The MCP servers use each app's NEXT_PUBLIC_APP_URL as their origin, so there is no separate MCP_APP_URL in this kit. Your User MCP is <web origin>/mcp/user and your Admin MCP is <admin origin>/mcp/admin.
Realtime dispatcher in production
Realtime events are written to the outbox_events table and published to Centrifugo by apps/web/src/cli/dispatch-outbox.ts. Locally pnpm local:start keeps it running. The repository has no cron route or scheduled job for production, so if you want realtime in production, run pnpm --filter web realtime:dispatch:once on a schedule from a job runner you control, or run the dispatcher as a long-lived process somewhere that can reach both Neon and Centrifugo. Without a dispatcher, the app still works; the browser just doesn't receive live invalidation events.
Deploy and check
Run pnpm verify:local before you push. Then deploy both projects, open the site and check:
/loginand/sign-upload on the web origin. Create a test account, open the verification email from Resend, and sign in.- Complete onboarding, create a workspace and add a project.
/mcp/connections/useron the web origin shows a server address on your domain./.well-known/oauth-protected-resource/mcp/userreturns JSON. Opening/mcp/userin a browser returns 401 with aWWW-Authenticatechallenge, which is correct.- Follow connect an agent to approve a connection and call
projects_list. - Promote your own account to application administrator as described in authentication. Sign in at
/loginon the admin origin, open/app-admin, and check/mcp/connections/adminthere. The web app's sidebar should now show Open admin panel. - From the admin users page, impersonate the test account. You should land on the web app's
/dashboardwith the impersonation banner, and stopping should return you to the admin users page. Edit a blog post in the admin CMS and confirm the public page on the web origin refreshes; if it doesn't,REVALIDATE_SECRETorNEXT_PUBLIC_WEB_APP_URLon the admin project is wrong. - If you enabled Stripe, send a test webhook and confirm the billing page updates.
If sign-in loops or emails link to the wrong host, compare BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL of the app you are on with the address in the browser first. More cases are in troubleshooting.