From onex
Add Better Auth plus a Neon Postgres database to a Next.js App Router app using raw SQL — no ORM. Scaffolds a raw-SQL migration runner (`yarn db:migrate`) and an idempotent admin seeder (`yarn db:seed`), wires email+password / passwordless magic-link / email-OTP authentication via Resend, builds a compact login / sign-up / forgot-password dialog with the logo top-centered, and updates `.env.example`. Asks at run time whether to add Google/LinkedIn OAuth and SMS OTP as extra login methods, and whether the app is a multi-tenant SaaS (Better Auth organization model + seeded org). Use when the user says "/onex:add-auth-db", "add auth and a database", "set up Better Auth with Neon", "add login to this app", "scaffold authentication", or wants authentication + Postgres without an ORM.
How this skill is triggered — by the user, by Claude, or both
Slash command
/onex:add-auth-dbThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Add a complete authentication layer and a Neon Postgres database to a Next.js App Router app. The database is driven by **raw SQL** — there is no ORM, no schema-as-code, no query builder for app code. Migrations are plain `.sql` files run by a small custom runner; app queries use the Neon driver directly.
Add a complete authentication layer and a Neon Postgres database to a Next.js App Router app. The database is driven by raw SQL — there is no ORM, no schema-as-code, no query builder for app code. Migrations are plain .sql files run by a small custom runner; app queries use the Neon driver directly.
This skill is a build procedure. Work through the steps in order — every step depends on decisions locked in Step 1. Do not write code until scope is confirmed.
lib/db.ts — Neon Postgres client, DATABASE_URL sourced from .env.local.migrations/*.sql — numbered raw-SQL migration files (0001_auth.sql, 0002_profile.sql, …).scripts/migrate.ts + scripts/seed.ts — wired to yarn db:migrate and yarn db:seed.lib/auth.ts, lib/auth-client.ts, app/api/auth/[...all]/route.ts — Better Auth server, client, and route handler.lib/email.ts + emails/* — Resend client and React Email templates.components/auth-dialog.tsx — login / sign-up / forgot-password dialog, logo top-centered, sm:max-w-md.profile table (1:1 with the auth user) and a seeded admin user (and organization, if multi-tenant)..env.example listing every variable the app needs.Before anything, inspect the repo and confirm the ground truth:
yarn; substitute if the project uses another.DATABASE_URL./better-auth/better-auth) or the better-auth-best-practices skill if installed.Two run-time decisions change the plugins, schema, env vars, dialog, and seed. Resolve both with AskUserQuestion before installing anything.
Baseline (always included, no need to ask): email + password, passwordless magic link, and email OTP.
Extra login methods (multi-select — the user may pick none):
socialProviders.google, adds GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET.socialProviders.linkedin, adds LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET.phoneNumber plugin. If chosen, confirm the SMS provider (default Twilio) and add the provider's env vars.Is this a multi-tenant SaaS? (yes / no):
organization plugin (organizations, members, invitations). The schema gains org tables and the seed creates a starter organization with the admin as owner.Lock both answers before continuing. Everything downstream branches on them.
Core: better-auth, @neondatabase/serverless, resend, @react-email/components.
Dev: @better-auth/cli, tsx, dotenv.
Conditional: twilio (or the chosen SMS provider) only if SMS OTP was selected.
Add the scripts to package.json:
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/seed.ts"
lib/db.ts — export a Neon Pool (and/or the neon() HTTP client) reading process.env.DATABASE_URL. Next.js loads .env.local automatically, so app code needs no extra config. All app queries are parameterised raw SQL through this client — never string-interpolate user input.
migrations/ — a folder of numbered .sql files, applied in lexical order: 0001_auth.sql, 0002_profile.sql, …
scripts/migrate.ts — a small runner that:
.env.local explicitly — config({ path: ".env.local" }) (standalone scripts do not get Next.js's env loading)._migrations(name text primary key, applied_at timestamptz default now()).migrations/*.sql sorted by filename, skips any already in _migrations.BEGIN → file SQL → INSERT INTO _migrations → COMMIT; rollback on error). Postgres DDL is transactional, so a failed migration leaves nothing half-applied.scripts/seed.ts — see Step 9. Scripts use relative imports (../lib/auth) so tsx resolves them without alias config.
Magic link, email OTP, email verification, and password reset all require sending email.
lib/email.ts — a Resend client (RESEND_API_KEY) and a typed sendEmail({ to, subject, react }) helper using EMAIL_FROM.emails/ — React Email templates: magic-link.tsx, email-otp.tsx, verify-email.tsx, reset-password.tsx. Keep them simple and on-brand; reuse the project's logo.EMAIL_FROM must use a Resend-verified domain — note this in the env comments.lib/auth.ts — betterAuth({ ... }):
database: a Pool from @neondatabase/serverless (Better Auth detects the Postgres dialect — no ORM needed).emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword } — reset email via Resend.emailVerification: { sendVerificationEmail } — via Resend.socialProviders: add google / linkedin only if chosen in Step 1.plugins, in this order:
magicLink({ sendMagicLink }) — passwordless link, via Resend.emailOTP({ sendVerificationOTP }) — email OTP code, via Resend.admin() — role-based access; the seeded user becomes role: "admin".organization() — only if multi-tenant.phoneNumber({ sendOTP }) — only if SMS OTP chosen; sends via the chosen provider.nextCookies() — MUST be last in the array.BETTER_AUTH_SECRET and BETTER_AUTH_URL.lib/auth-client.ts — createAuthClient from better-auth/react with the matching client plugins: magicLinkClient(), emailOTPClient(), adminClient(), and conditionally organizationClient() / phoneNumberClient().
app/api/auth/[...all]/route.ts — export const { GET, POST } = toNextJsHandler(auth). This catch-all is required by Better Auth; it is not an app API route, so it does not conflict with the "prefer server actions" convention.
npx @better-auth/cli generate to emit the Postgres SQL for the full schema (user, session, account, verification, plus admin columns, magic-link/OTP tables, and — if multi-tenant — organization / member / invitation).migrations/0001_auth.sql. Do not run @better-auth/cli migrate — it bypasses the migration files; this skill owns migrations.migrations/0002_profile.sql by hand — the profile table, 1:1 with the auth user:create table profile (
id text primary key,
user_id text not null unique references "user"(id) on delete cascade,
display_name text,
avatar_url text,
bio text,
phone text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Better Auth's user table is "user" (a reserved word — always double-quote it) and its columns are camelCase ("emailVerified", "createdAt", "organizationId", …). When you write raw SQL against Better Auth tables, copy the exact quoted names from 0001_auth.sql.
If plugins change later, re-run generate and add a new numbered migration with the delta — never edit an already-applied file.
components/auth-dialog.tsx ("use client", placed in @/components, named export):
Dialog; override width with sm:max-w-md on DialogContent (the sm: prefix is required — the base width is already set).DialogHeader. Reuse the project's logo component or /public asset; use a placeholder only if none exists.DialogTitle under the logo, changing per view ("Welcome back" / "Create your account" / "Reset your password") — never omit it.view state: login · signup · forgot · plus the magic-link-sent and OTP-entry states.authClient.forgetPassword. Also create a /reset-password page to consume the emailed token via authClient.resetPassword (the dialog requests the reset; the page completes it).authClient.signIn.email, signUp.email, signIn.magicLink, emailOtp.sendVerificationOtp + signIn.emailOtp, forgetPassword, signIn.social, and the phone-OTP methods when enabled.AuthDialogProvider + useAuthDialog() so any component can open the dialog at a given view.Conventions: any Popover/Dropdown/Combobox nested in the dialog needs the modal prop; for overflow use CSS overflow-y-auto with a max-height — never the shadcn ScrollArea.
Update .env.example with every variable, grouped and commented. The real secrets live in .env.local (git-ignored) — never commit them.
# Database (Neon) — used by the app and by db:migrate / db:seed
DATABASE_URL=
# Better Auth
BETTER_AUTH_SECRET= # generate: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Email (Resend) — EMAIL_FROM must use a Resend-verified domain
RESEND_API_KEY=
EMAIL_FROM="App <[email protected]>"
# Seed — the first admin user
SEED_ADMIN_NAME="Admin"
[email protected]
SEED_ADMIN_PASSWORD= # strong password; the seed hashes it via Better Auth
# --- Conditional, only if selected in Step 1 ---
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# LINKEDIN_CLIENT_ID=
# LINKEDIN_CLIENT_SECRET=
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# TWILIO_PHONE_NUMBER=
# --- Multi-tenant only ---
# SEED_ORG_NAME="Acme"
# SEED_ORG_SLUG=acme
Tell the user to copy .env.example to .env.local and fill it in before running migrate/seed.
scripts/seed.ts — idempotent, loads .env.local first:
SELECT id FROM "user" WHERE email = $1 — if the admin already exists, log and exit 0.auth.api.signUpEmail({ body: { name, email, password } }) — so the password is hashed correctly. Never hand-write a password hash.UPDATE "user" SET role = 'admin', "emailVerified" = true WHERE email = $1.profile row (raw SQL, id = crypto.randomUUID()).organization row, then a member row linking the admin with role = 'owner' (raw SQL; use the exact quoted column names from 0001_auth.sql).The seed password comes only from SEED_ADMIN_PASSWORD — never hardcode it.
yarn db:migrate — confirm 0001_auth.sql and 0002_profile.sql apply and land in _migrations.yarn db:seed — confirm the admin user, profile (and org, if multi-tenant) are created; re-run once to confirm it is idempotent.yarn build (or typecheck) — no type errors.nextCookies() is always the last Better Auth plugin..env.local. .env.example holds only keys and comments."user" is reserved — double-quote names in raw SQL.@/components; use named exports; client components get "use client".sm: prefix; every Dialog has a DialogTitle; no shadcn ScrollArea.npx claudepluginhub onextech/skills --plugin onexProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.