Add auth db
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.From its SKILL.md
npx -y skills add onextech/skills --skill add-auth-dbAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
14.3 KB, as published. Nobody here has run it
/onex:add-auth-db — Add Better Auth + Neon (raw SQL, no ORM)
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.
What this skill produces
lib/db.ts— Neon Postgres client,DATABASE_URLsourced from.env.local.migrations/*.sql— numbered raw-SQL migration files (0001_auth.sql,0002_profile.sql, …).scripts/migrate.ts+scripts/seed.ts— wired toyarn db:migrateandyarn 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.- A
profiletable (1:1 with the auth user) and a seeded admin user (and organization, if multi-tenant). - An updated
.env.examplelisting every variable the app needs.
Preflight — detect the stack
Before anything, inspect the repo and confirm the ground truth:
- Confirm Next.js App Router + TypeScript. If it is Pages Router or not Next.js, stop and tell the user this skill targets the App Router.
- Detect the package manager (lockfile). Commands below use
yarn; substitute if the project uses another. - Detect shadcn/ui + Tailwind (needed for the dialog). If absent, note it and offer to initialise shadcn first.
- Check for an existing database, ORM, or Better Auth setup. If found, do not clobber it — surface what exists and ask how to proceed.
- Confirm a Neon database is available (or that the user will create one) — the skill needs a
DATABASE_URL. - Better Auth's API shifts between versions. Before writing config, confirm the current API via context7 (
/better-auth/better-auth) or thebetter-auth-best-practicesskill if installed.
Step 1 — Confirm scope (ask the user — REQUIRED)
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):
- Google OAuth —
socialProviders.google, addsGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET. - LinkedIn OAuth —
socialProviders.linkedin, addsLINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET. - SMS OTP — Better Auth
phoneNumberplugin. If chosen, confirm the SMS provider (default Twilio) and add the provider's env vars.
- Google OAuth —
-
Is this a multi-tenant SaaS? (yes / no):
- Yes — add the Better Auth
organizationplugin (organizations, members, invitations). The schema gains org tables and the seed creates a starter organization with the admin as owner. - No — single-tenant; skip the organization plugin entirely.
- Yes — add the Better Auth
Lock both answers before continuing. Everything downstream branches on them.
Step 2 — Install dependencies
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"
Step 3 — Database foundation (raw SQL, no ORM)
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:
- Loads
.env.localexplicitly —config({ path: ".env.local" })(standalone scripts do not get Next.js's env loading). - Creates a ledger table if missing:
_migrations(name text primary key, applied_at timestamptz default now()). - Reads
migrations/*.sqlsorted by filename, skips any already in_migrations. - For each pending file: run it inside a transaction (
BEGIN→ file SQL →INSERT INTO _migrations→COMMIT; rollback on error). Postgres DDL is transactional, so a failed migration leaves nothing half-applied. - Logs each applied file and exits non-zero on failure.
scripts/seed.ts — see Step 9. Scripts use relative imports (../lib/auth) so tsx resolves them without alias config.
Step 4 — Email delivery (Resend)
Magic link, email OTP, email verification, and password reset all require sending email.
lib/email.ts— a Resend client (RESEND_API_KEY) and a typedsendEmail({ to, subject, react })helper usingEMAIL_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_FROMmust use a Resend-verified domain — note this in the env comments.
Step 5 — Better Auth configuration
lib/auth.ts — betterAuth({ ... }):
database: aPoolfrom@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: addgoogle/linkedinonly 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 becomesrole: "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.
- Set
BETTER_AUTH_SECRETandBETTER_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.
Step 6 — Schema & migrations
- With all plugins from Step 5 configured, run
npx @better-auth/cli generateto 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). - Save that SQL verbatim as
migrations/0001_auth.sql. Do not run@better-auth/cli migrate— it bypasses the migration files; this skill owns migrations. - Write
migrations/0002_profile.sqlby hand — theprofiletable, 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.
Step 7 — The auth dialog
components/auth-dialog.tsx ("use client", placed in @/components, named export):
- A shadcn
Dialog; override width withsm:max-w-mdonDialogContent(thesm:prefix is required — the base width is already set). - Logo top-centered in the
DialogHeader. Reuse the project's logo component or/publicasset; use a placeholder only if none exists. - A required
DialogTitleunder the logo, changing per view ("Welcome back" / "Create your account" / "Reset your password") — never omit it. - A
viewstate:login·signup·forgot· plus the magic-link-sent and OTP-entry states. - Login — email + password, "Forgot password?" link, and toggles for magic link and email OTP. Social buttons (Google/LinkedIn) and the phone/SMS-OTP path render only if enabled in Step 1.
- Sign up — name, email, password.
- Forgot password — email field; calls
authClient.forgetPassword. Also create a/reset-passwordpage to consume the emailed token viaauthClient.resetPassword(the dialog requests the reset; the page completes it). - Use the client methods:
authClient.signIn.email,signUp.email,signIn.magicLink,emailOtp.sendVerificationOtp+signIn.emailOtp,forgetPassword,signIn.social, and the phone-OTP methods when enabled. - Validate with zod + react-hook-form; surface success/error with the project's toast (e.g. sonner).
- Expose an
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.
Step 8 — Environment variables
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.
Step 9 — Seed the first admin
scripts/seed.ts — idempotent, loads .env.local first:
SELECT id FROM "user" WHERE email = $1— if the admin already exists, log and exit 0.- Create the user with Better Auth's server API —
auth.api.signUpEmail({ body: { name, email, password } })— so the password is hashed correctly. Never hand-write a password hash. - Promote via raw SQL:
UPDATE "user" SET role = 'admin', "emailVerified" = true WHERE email = $1. - Insert the
profilerow (raw SQL,id = crypto.randomUUID()). - If multi-tenant — insert the
organizationrow, then amemberrow linking the admin withrole = 'owner'(raw SQL; use the exact quoted column names from0001_auth.sql). - Log a clear success summary.
The seed password comes only from SEED_ADMIN_PASSWORD — never hardcode it.
Step 10 — Run & verify
yarn db:migrate— confirm0001_auth.sqland0002_profile.sqlapply 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.- Smoke test: open the auth dialog, sign in as the seeded admin, request a magic link and an email OTP, exercise forgot-password. Confirm each email arrives via Resend.
- Summarise for the user: what was created, which env vars they must fill, and any follow-ups (e.g. OAuth callback URLs to register, Resend domain verification, and — if multi-tenant — that org-switcher UI is intentionally left as a follow-up; this skill only prepares multi-tenancy).
Conventions
- Raw SQL only. No ORM, no query builder for app code. Parameterised queries always — never interpolate input.
- Migrations are append-only. Numbered files, never edit an applied one, ledger-tracked.
nextCookies()is always the last Better Auth plugin.- Secrets stay in
.env.local..env.exampleholds only keys and comments. - Better Auth tables are camelCase and
"user"is reserved — double-quote names in raw SQL. - New components go in
@/components; use named exports; client components get"use client". - Dialog width override uses the
sm:prefix; every Dialog has aDialogTitle; no shadcnScrollArea. - Seed scripts are idempotent and re-runnable.
When NOT to use this skill
- The app already has a working auth system — extend it, don't replace it.
- The user wants an ORM (Prisma, Drizzle) — this skill is deliberately raw-SQL.
- It is not a Next.js App Router app.
- The user wants only a database, or only auth UI, with none of the rest — do that part directly instead.