A Better Auth community plugin that adds waitlist and early-access gating to your authentication system. Intercepts all registration paths and gates sign-ups behind an invite-based waitlist.
- Intercepts all registration paths -- email/password, OAuth, magic link, OTP, phone, anonymous, one-tap, and SIWE are all gated automatically
- Dual-layer protection -- hooks intercept requests before processing and database hooks block user creation as a safety net
- Admin dashboard endpoints -- approve, reject, bulk approve, list entries, and view statistics
- Invite code system -- unique codes with configurable expiration (default 48 hours)
- Auto-approve mode -- pass
trueto approve everyone, or a function for conditional logic - Bulk approve -- approve specific emails or the next N entries in the queue
- Referral tracking -- track referrals and attach arbitrary JSON metadata to entries
- Lifecycle callbacks --
onJoinWaitlist,onApproved,onRejected, andsendInviteEmailfor email notifications - Full TypeScript support -- type-safe client and server APIs with inference
- Works with any Better Auth adapter -- Prisma 5/6/7, Drizzle, MongoDB, SQLite, MySQL, PostgreSQL, and more
- Framework agnostic -- Next.js 14-16, Nuxt, SvelteKit, Solid, Remix, Hono, Express, and any other framework Better Auth supports
better-auth>= 1.0.0- Node.js >= 18 (or Bun, Deno, etc.)
npm install @guilhermejansen/better-auth-waitlistpnpm add @guilhermejansen/better-auth-waitlistbun add @guilhermejansen/better-auth-waitlistyarn add @guilhermejansen/better-auth-waitlistimport { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins/admin";
import { waitlist } from "@guilhermejansen/better-auth-waitlist";
export const auth = betterAuth({
// ... your config
plugins: [
admin(), // Required for admin role checking
waitlist({
requireInviteCode: true,
sendInviteEmail: async ({ email, inviteCode, expiresAt }) => {
await sendEmail({
to: email,
subject: "You're invited!",
body: `Use code: ${inviteCode}`,
});
},
}),
],
});import { createAuthClient } from "better-auth/client";
import { waitlistClient } from "@guilhermejansen/better-auth-waitlist/client";
export const authClient = createAuthClient({
plugins: [waitlistClient()],
});These endpoints are available without authentication.
const { data, error } = await authClient.waitlist.join({
email: "user@example.com",
referredBy: "friend-id", // optional
metadata: { source: "landing-page" }, // optional
});
// data: { id, email, status, position, createdAt }const { data } = await authClient.waitlist.status({
email: "user@example.com",
});
// data: { status: "pending" | "approved" | "rejected" | "registered", position: number }const { data } = await authClient.waitlist.verifyInvite({
inviteCode: "abc-123-def",
});
// data: { valid: boolean, email: string | null }When requireInviteCode is enabled, pass the invite code during sign-up:
const { data } = await authClient.signUp.email({
email: "user@example.com",
password: "securepassword",
name: "User",
inviteCode: "abc-123-def", // Required when requireInviteCode is true
});Or via header:
const { data } = await authClient.signUp.email(
{ email: "user@example.com", password: "securepassword", name: "User" },
{ headers: { "x-invite-code": "abc-123-def" } },
);All admin endpoints require an authenticated session with an admin role.
await auth.api.approveEntry({
body: { email: "user@example.com" },
});await auth.api.rejectEntry({
body: { email: "user@example.com", reason: "Not qualified" },
});// Approve specific emails
await auth.api.bulkApprove({
body: { emails: ["a@test.com", "b@test.com"] },
});
// Approve next N entries in the queue (ordered by position)
await auth.api.bulkApprove({
body: { count: 10 },
});const data = await auth.api.listWaitlist({
query: {
status: "pending", // optional: filter by status
page: 1,
limit: 20,
sortBy: "createdAt", // "createdAt" | "position" | "email" | "status"
sortDirection: "desc", // "asc" | "desc"
},
});
// data: { entries: WaitlistEntry[], total: number, page: number, totalPages: number }const stats = await auth.api.getWaitlistStats();
// stats: { total, pending, approved, rejected, registered }| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable or disable the waitlist gate |
requireInviteCode |
boolean |
false |
Require an invite code during registration |
inviteCodeExpiration |
number |
172800 |
Invite code TTL in seconds (48 hours) |
maxWaitlistSize |
number |
undefined |
Maximum number of entries allowed on the waitlist |
skipAnonymous |
boolean |
false |
Skip waitlist checks for anonymous sign-ins |
autoApprove |
boolean | (email: string) => boolean | Promise<boolean> |
undefined |
Auto-approve entries on join. Pass true for all, or a function for conditional logic |
interceptPaths |
string[] |
All registration paths | Override which Better Auth paths are intercepted |
adminRoles |
string[] |
["admin"] |
Roles that are allowed to perform admin actions |
onJoinWaitlist |
(entry: WaitlistEntry) => void | Promise<void> |
undefined |
Called after an entry joins the waitlist |
onApproved |
(entry: WaitlistEntry) => void | Promise<void> |
undefined |
Called after an entry is approved |
onRejected |
(entry: WaitlistEntry) => void | Promise<void> |
undefined |
Called after an entry is rejected |
sendInviteEmail |
(data: { email, inviteCode, expiresAt }) => void | Promise<void> |
undefined |
Called on approval to deliver the invite code |
schema |
object |
undefined |
Customize table and field names |
When interceptPaths is not set, these registration paths are intercepted:
/sign-up/email/callback/(OAuth)/oauth2/callback/(OAuth2)/magic-link/verify/sign-in/email-otp/email-otp/verify-email/phone-number/verify/sign-in/anonymous/one-tap/callback/siwe/verify
The plugin creates a waitlist table with the following fields:
| Field | Type | Description |
|---|---|---|
id |
string |
Primary key |
email |
string |
Email address (unique, indexed) |
status |
string |
pending / approved / rejected / registered |
inviteCode |
string? |
Unique invite code (generated on approval) |
inviteExpiresAt |
date? |
Invite code expiration timestamp |
position |
number? |
Queue position (assigned on join) |
referredBy |
string? |
Referral identifier |
metadata |
string? |
JSON-serialized metadata |
approvedAt |
date? |
Approval timestamp |
rejectedAt |
date? |
Rejection timestamp |
registeredAt |
date? |
Registration timestamp |
createdAt |
date |
Created timestamp |
updatedAt |
date |
Updated timestamp |
The plugin uses a dual-layer interception strategy to ensure no unapproved user can register, regardless of which authentication method they use:
-
Hooks Layer --
hooks.beforeintercepts registration endpoints and validates waitlist status before the request is processed. This catches email/password sign-ups, OTP, magic links, and any path that includes the email in the request body. -
Database Hooks Layer --
databaseHooks.user.create.beforeacts as a safety net, blocking user creation at the database level if the email does not have an approved waitlist entry. This catches OAuth callbacks and any other flow where the email is not available in the request body. -
Post-Registration --
databaseHooks.user.create.afterautomatically marks the waitlist entry asregisteredafter successful sign-up, preventing the invite code from being reused.
You can customize the table and field names to match your existing database conventions:
waitlist({
schema: {
waitlist: {
modelName: "WaitlistEntry", // Custom table name
fields: {
email: "emailAddress", // Custom field names
},
},
},
});The plugin exports WAITLIST_ERROR_CODES for programmatic error handling:
| Code | Message |
|---|---|
EMAIL_ALREADY_IN_WAITLIST |
This email is already on the waitlist |
WAITLIST_ENTRY_NOT_FOUND |
Waitlist entry not found |
NOT_APPROVED |
You must be approved from the waitlist to register |
INVALID_INVITE_CODE |
Invalid or expired invite code |
INVITE_CODE_REQUIRED |
An invite code is required to register |
ALREADY_REGISTERED |
This waitlist entry has already been used for registration |
WAITLIST_FULL |
The waitlist is currently full |
UNAUTHORIZED_ADMIN_ACTION |
You are not authorized to perform this action |
import { WAITLIST_ERROR_CODES } from "@guilhermejansen/better-auth-waitlist";
if (error.message === WAITLIST_ERROR_CODES.NOT_APPROVED) {
// Handle not approved
}See CONTRIBUTING.md for guidelines on how to contribute to this project.
MIT -- Guilherme Jansen
Built with love for the open source community by Guilherme Jansen.
I built this plugin because manually implementing waitlist gating for every SaaS project was a recurring pain point. Now I use it in production across all my projects, including InsightZap. I hope it saves you time too.