Add Better Auth to an existing project
For projects scaffolded without auth that now need it. This is a substantial change — every tRPC procedure becomes auth-gated, and the schema gains four new tables.
Use when
- User wants user accounts / login / sessions.
- User says "add auth" / "wire Better Auth" / "we need permissions now".
- User invokes
/nts-add-auth.
Confirmation flow
Auth is a meaningful schema change. Before writing:
- Verify project structure (
prisma/schema.prisma,src/server/,package.jsonexist). - Confirm auth strategy:
- Credentials only (recommended) — email + password.
- Credentials + magic-link — adds Resend dependency.
- Credentials + OAuth providers (Google, GitHub, etc.) — adds provider configuration.
- Confirm the user has a place to put the bootstrap admin (sysadmin) — usually the seed script.
- Show all file changes. Confirm.
What this skill does
- Adds
better-authtopackage.json. - Adds Better Auth's required Prisma models —
User,Session,Account,Verification. - Creates a migration.
- Creates
src/server/auth/index.tswith the Better Auth config. - Creates
src/server/auth/session.tswithrequireSession()andgetSession()helpers. - Creates
src/server/auth/permissions.tswithrequirePermission()(RBAC). AddsRole,Permission,UserRole,RolePermissionPrisma models. - Creates
src/app/api/auth/[...all]/route.ts— Better Auth's catch-all handler. - Creates
src/app/(auth)/login/page.tsx— credentials login form. - Updates
src/server/api/trpc.ts— context now includes session; addsprotectedProcedure. - Updates existing tRPC routers — switches
publicProceduretoprotectedProcedurewhere appropriate. - Updates existing services — adds
userIdfirst parameter +requirePermissionif not already present. - Updates
prisma/seed.tsto seed default roles + permissions + a bootstrap admin. - Updates
CLAUDE.mdto document the auth layer.
Prisma models to add
model User {
id String @id @default(cuid())
email String @unique
emailVerified Boolean @default(false)
name String?
image String?
isSysadmin Boolean @default(false) @map("is_sysadmin")
status String @default("active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
sessions Session[]
accounts Account[]
userRoles UserRole[]
@@map("users")
}
model Session {
id String @id @default(cuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
accountId String @map("account_id")
providerId String @map("provider_id")
accessToken String? @map("access_token")
refreshToken String? @map("refresh_token")
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
scope String?
idToken String? @map("id_token")
password String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("accounts")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("verifications")
}
model Role {
id String @id @default(cuid())
name String @unique
description String?
createdAt DateTime @default(now()) @map("created_at")
permissions RolePermission[]
users UserRole[]
@@map("roles")
}
model Permission {
id String @id @default(cuid())
scope String @unique
roles RolePermission[]
@@map("permissions")
}
model UserRole {
userId String @map("user_id")
roleId String @map("role_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@map("user_roles")
}
model RolePermission {
roleId String @map("role_id")
permissionId String @map("permission_id")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@map("role_permissions")
}
Files to create / modify
src/server/auth/index.ts (new)
import "server-only";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/server/db/client";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
plugins: [nextCookies()],
});
src/server/auth/session.ts (new)
import "server-only";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "./index";
export async function getSession() {
return auth.api.getSession({ headers: await headers() });
}
export async function requireSession() {
const session = await getSession();
if (!session) redirect("/login");
return session;
}
src/server/auth/permissions.ts (new)
import "server-only";
import { db } from "@/server/db/client";
import { ForbiddenError } from "@/server/lib/errors";
export type PermissionScope = string; // tighten with a union once you have your scope catalog
export async function requirePermission(userId: string, scope: PermissionScope) {
const user = await db.user.findUnique({ where: { id: userId }, select: { isSysadmin: true } });
if (user?.isSysadmin) return;
const allowed = await db.rolePermission.findFirst({
where: { role: { users: { some: { userId } } }, permission: { scope } },
select: { permissionId: true },
});
if (!allowed) throw new ForbiddenError(`Missing permission: ${scope}`);
}
src/app/api/auth/[...all]/route.ts (new)
import { auth } from "@/server/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);
src/app/(auth)/login/page.tsx (new)
Standard credentials form — name field + email + password — POSTs to Better Auth's /api/auth/sign-in/email. Use the Better Auth client (authClient.signIn.email({...})) if you prefer.
Update src/server/api/trpc.ts
Add session to context, expose protectedProcedure. See assets/trpc-server.ts.template from scaffold-internal-tool — the {{#IF_AUTH}} branch.
Update existing routers and services
Walk every router file in src/server/api/routers/ and replace publicProcedure with protectedProcedure where the procedure shouldn't be anonymous. Walk every service and verify the userId-first signature + requirePermission call.
Seed roles and permissions
Add to prisma/seed.ts:
async function seedRolesAndPermissions() {
const scopes = ["customers:read", "customers:write", /* ... */];
await Promise.all(
scopes.map((scope) =>
db.permission.upsert({ where: { scope }, update: {}, create: { scope } })
)
);
const admin = await db.role.upsert({
where: { name: "admin" }, update: {}, create: { name: "admin" },
});
// Wire all permissions to admin role
for (const scope of scopes) {
const perm = await db.permission.findUniqueOrThrow({ where: { scope } });
await db.rolePermission.upsert({
where: { roleId_permissionId: { roleId: admin.id, permissionId: perm.id } },
update: {}, create: { roleId: admin.id, permissionId: perm.id },
});
}
}
Verification
pnpm install
pnpm prisma migrate dev --name add_auth
pnpm db:seed
pnpm tsc --noEmit
pnpm build
Then guide the user to:
- Sign up a first user via the login page.
- Manually update the User row in Prisma Studio to set
isSysadmin = true. - Sign in — they should now have access to everything.
The bootstrap-admin step is manual to keep this skill stack-agnostic. Document the step in CLAUDE.md.