Skip to content

Waitlist / early-access plugin for Better Auth. Intercepts all registration paths and gates sign-ups behind an invite-based waitlist.

License

Notifications You must be signed in to change notification settings

guilhermejansen/better-auth-waitlist

Repository files navigation

@guilhermejansen/better-auth-waitlist

npm version npm downloads license CI
TypeScript Better Auth bundle size PRs Welcome

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.

Features

  • 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 true to 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, and sendInviteEmail for 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

Requirements

  • better-auth >= 1.0.0
  • Node.js >= 18 (or Bun, Deno, etc.)

Installation

npm install @guilhermejansen/better-auth-waitlist
pnpm add @guilhermejansen/better-auth-waitlist
bun add @guilhermejansen/better-auth-waitlist
yarn add @guilhermejansen/better-auth-waitlist

Quick Start

Server Setup

import { 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}`,
        });
      },
    }),
  ],
});

Client Setup

import { createAuthClient } from "better-auth/client";
import { waitlistClient } from "@guilhermejansen/better-auth-waitlist/client";

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

API Reference

Public Endpoints

These endpoints are available without authentication.

Join the Waitlist

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 }

Check Waitlist Status

const { data } = await authClient.waitlist.status({
  email: "user@example.com",
});
// data: { status: "pending" | "approved" | "rejected" | "registered", position: number }

Verify Invite Code

const { data } = await authClient.waitlist.verifyInvite({
  inviteCode: "abc-123-def",
});
// data: { valid: boolean, email: string | null }

Register with Invite Code

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" } },
);

Admin Endpoints

All admin endpoints require an authenticated session with an admin role.

Approve Entry

await auth.api.approveEntry({
  body: { email: "user@example.com" },
});

Reject Entry

await auth.api.rejectEntry({
  body: { email: "user@example.com", reason: "Not qualified" },
});

Bulk Approve

// 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 },
});

List Entries

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 }

Get Statistics

const stats = await auth.api.getWaitlistStats();
// stats: { total, pending, approved, rejected, registered }

Configuration Options

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

Default Intercepted Paths

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

Database Schema

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

How It Works

The plugin uses a dual-layer interception strategy to ensure no unapproved user can register, regardless of which authentication method they use:

  1. Hooks Layer -- hooks.before intercepts 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.

  2. Database Hooks Layer -- databaseHooks.user.create.before acts 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.

  3. Post-Registration -- databaseHooks.user.create.after automatically marks the waitlist entry as registered after successful sign-up, preventing the invite code from being reused.

Schema Customization

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
      },
    },
  },
});

Error Codes

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
}

Contributing

See CONTRIBUTING.md for guidelines on how to contribute to this project.

License

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.

About

Waitlist / early-access plugin for Better Auth. Intercepts all registration paths and gates sign-ups behind an invite-based waitlist.

Resources

License

Contributing

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published

Contributors 2

  •  
  •