Better Enrollment

Quick Start

Set up Better Enrollment step by step, for a closed app or open sign-up.

Better Enrollment runs in one of two modes, and the setup flow is the same for both; only your auth config differs. Pick your path once and the examples on this page follow it:

  • Invite-only (closed app): sign-up is disabled and invitations are the only way in.
  • Open sign-up: anyone can sign up, and redeeming an invite grants roles and organization membership.

Add the plugin to your auth config

Import betterEnrollment and add it to your betterAuth plugins.

Sign-up stays disabled; the plugin sees the fully closed config and auto-detects invite-only mode. Users can only come in through an invitation.

auth.ts
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";
import { betterEnrollment } from "@octopi-ai/better-enrollment";

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    disableSignUp: true // closed: invitations are the only way in
  },
  plugins: [
    admin(),
    betterEnrollment({
      async sendPrivateInvitation({ email, url }) {
        await sendInvitationEmail(email, url);
      }
    })
  ]
});

mode: "auto" (the default) inspects your config at startup and picks the right mode; a mixed config throws and asks you to set mode explicitly. See The two modes for the details and its warning about sign-up paths added by other plugins.

Migrate the database

npx @better-auth/cli migrate
# or, to review the SQL first
npx @better-auth/cli generate

This creates the invite and inviteUse tables; see Database for every column.

Role field required

Invited roles are stored in a string role field on your user model, and the default admin gate reads it. The admin() plugin above provides one; if you skip that plugin, add the field via user.additionalFields or gate management with adminUserIds or canManageInvites instead.

Add the client plugin

Import betterEnrollmentClient and add it to your auth client:

auth-client.ts
import { createAuthClient } from "better-auth/react";
import { betterEnrollmentClient } from "@octopi-ai/better-enrollment/client";

export const authClient = createAuthClient({
  plugins: [betterEnrollmentClient()]
});

Create your first invite

Invites are created by an admin (or an org member with permission). Create them from an admin dashboard with the client, or from your server:

admin-dashboard.ts
// Requires the signed-in user to pass the admin gate
await authClient.invite.create({
  type: "private",
  email: "ada@example.com",
  name: "Ada",
  role: "user"
});

See Invites for org invites (org-join, org-create) and every option.

Build the invite page

Every invite link points at one page in your app, carrying only ?token=. The page calls invite.get, renders what the response says, and submits everything to invite.redeem; it never needs to know the invite kind or the mode.

app/invite/page.tsx
// 1. Look up the token from the URL
const { data: invite } = await authClient.invite.get({ token });

// 2. Render by invite.nextAction:
//    "SIGN_UP"  -> a form with the fields in invite.requiredFields
//    "SIGN_IN"  -> your sign-in form; keep the token in the URL
//    "CONFIRM"  -> a single confirm button
//    null       -> expired, revoked, or consumed; show a terminal message

// 3. Submit to one endpoint
await authClient.invite.redeem({ token, password, name, email });
// -> { action: "ACCEPTED", organization? }

// 4. Sign them in; redemption never creates a session
await authClient.signIn.email({ email, password });

The full rendering table and requiredFields reference are on The invite page.

What redemption does

The invitee has no account, so invite.get returns nextAction: "SIGN_UP". Redeeming creates the user and their credential account in one atomic step. No session is created, you should sign them in right after, via your application's designated login flow.".

Where to go next

Last updated on

On this page