LaunchApp
Auth8 min read

Better Auth SaaS Template: Complete Auth on Day One

See how LaunchApp's better auth SaaS template wires up Drizzle ORM, multi-tenancy, 2FA, passkeys, and API keys — all production-ready. Start building.

Auth is the part of every SaaS project where you make one wrong decision and pay for it for years. Tokens in the wrong place, session handling that breaks on mobile, OAuth flows that work locally but fail in production — most teams have a war story.

Every LaunchApp template ships with Better Auth already wired up: email/password, Google and GitHub OAuth, magic links, OTPs, passkeys, 2FA, organization multi-tenancy, and API keys. You do not write any of this. It is there on day one.

This post covers exactly how the better auth SaaS template setup works inside LaunchApp.

Why a Better Auth SaaS Template Makes Sense

The main alternatives at the time we made this decision were NextAuth (Auth.js), Lucia, and Clerk.

NextAuth is framework-coupled and has historically been Next.js-first. We run React Router 7 + Hono as the flagship stack, Next.js as a variant, Nuxt, and SvelteKit. We needed something adapter-based that works the same across all four.

Lucia is lower-level and requires more manual wiring. Fine if you want control; too much boilerplate if you want a template that ships fast.

Clerk is excellent but it is a managed service with per-MAU pricing. For a self-hosted template product, having auth behind a third-party SaaS gate is a non-starter for many buyers.

Better Auth is fully open source, TypeScript-first, ships a Drizzle adapter, and has a solid plugin system. The Drizzle integration was the deciding factor — our entire database layer is Drizzle, and having auth and app data in the same schema with the same query builder removes a whole category of N+1 and join problems.

The Package Structure

Auth lives in @repo/auth, a shared internal package that all apps import from:

packages/
  auth/
    src/
      auth.ts        # betterAuth() config — the single source of truth

The app layer is a one-liner:

// apps/web/app/server/auth.ts
export { auth } from "@repo/auth";

Every template variant (Next.js, Nuxt, SvelteKit) imports from @repo/auth the same way. The config is maintained once.

The Auth Config

Here is the full betterAuth() call, exactly as it lives in the codebase:

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, emailOTP, magicLink, twoFactor } from "better-auth/plugins";
import { organization } from "better-auth/plugins/organization";
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";

export const auth = betterAuth({
  appName: "LaunchApp",
  baseURL: env.BETTER_AUTH_URL,
  secret: env.BETTER_AUTH_SECRET,
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      user: users,
      session: sessions,
      account: accounts,
      twoFactor: twoFactorSchema,
      passkey: passkeys,
      apikey: apiKeys,
      verification: verifications,
      organization: organizations,
      member: members,
      invitation: invitations,
    },
  }),
  emailAndPassword: { enabled: true, sendResetPassword: ... },
  emailVerification: { sendVerificationEmail: ..., sendOnSignUp: true },
  socialProviders: {
    google: { clientId: env.GOOGLE_CLIENT_ID, ... },
    github: { clientId: env.GITHUB_CLIENT_ID, ... },
  },
  plugins: [
    admin(),
    apiKey(),
    emailOTP({ sendVerificationOTP: ... }),
    magicLink({ sendMagicLink: ... }),
    organization({ ... }),
    passkey(),
    twoFactor(),
  ],
});

Eleven plugins, one config object. Social providers are conditional on env vars — if you do not set GOOGLE_CLIENT_ID, Google OAuth does not appear. Zero dead code paths.

Drizzle Schema Integration

The Drizzle adapter maps Better Auth's internal tables to your own schema exports. This matters because:

  1. You control the migrations — no black-box foreign schema
  2. You can add columns to users without fighting the auth library
  3. You join auth data with app data in a single Drizzle query

The users table in LaunchApp templates extends the Better Auth baseline with SaaS-specific columns:

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  role: userRoleEnum("role").notNull().default("user"),
  banned: boolean("banned").notNull().default(false),
  banReason: text("ban_reason"),
  banExpires: timestamp("ban_expires"),
  // Stripe billing
  stripeCustomerId: text("stripe_customer_id"),
  stripeSubscriptionId: text("stripe_subscription_id"),
  subscriptionStatus: subscriptionStatusEnum("subscription_status"),
  subscriptionPeriodEnd: timestamp("subscription_period_end"),
  planTier: planTierEnum("plan_tier").notNull().default("free"),
  // App-level state
  twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
  onboardingCompleted: boolean("onboarding_completed").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

Stripe customer IDs and subscription status live on the same row as the Better Auth user. No cross-service joins. When you load the current user in a loader, you get billing state for free.

Multi-Tenancy via the Organization Plugin

The organization plugin gives you multi-tenant workspaces without writing any of the membership logic yourself:

organization({
  allowUserToCreateOrganization: true,
  organizationLimit: 5,
  creatorRole: "owner",
  membershipLimit: 50,
  roles: {
    ...defaultRoles,          // owner, admin, member
    guest: defaultAc.newRole({}) as Role,
  },
  invitationExpiresIn: 48 * 60 * 60,
  async sendInvitationEmail({ email, organization: org, inviter }) {
    void emailService.sendTemplate("organization-invitation", email, {
      inviterName: inviter.user.name,
      orgName: org.name,
      acceptUrl: `${env.BETTER_AUTH_URL}/dashboard/invitations`,
    });
  },
}),

Out of the box you get:

  • Organization creation, update, deletion
  • Role-based membership (owner, admin, member, guest)
  • Invitation flow with email delivery and 48-hour expiry
  • A user can belong to up to 5 organizations
  • Membership cap at 50 per org

This covers the standard B2B SaaS model where one account can have multiple workspaces and invite team members. The access control rules come from Better Auth's defaultAc layer — you can customize them without building the RBAC system yourself.

Email Flows

Every auth email is wired through @repo/email, which uses Resend under the hood. The templates are typed:

emailAndPassword: {
  enabled: true,
  sendResetPassword: async ({ user, url }) => {
    void emailService.sendTemplate("password-reset", user.email, {
      name: user.name,
      resetUrl: url,
    });
  },
},
emailVerification: {
  sendVerificationEmail: async ({ user, url }) => {
    void emailService.sendTemplate("verification", user.email, {
      name: user.name,
      verificationUrl: url,
    });
  },
  sendOnSignUp: true,
},

The email service call is void-prefixed intentionally: failed emails do not block auth flows. Users can complete sign-up even if Resend has a transient outage. The email is best-effort; the session is not.

Magic links and OTPs follow the same pattern:

magicLink({
  async sendMagicLink({ email, url }) {
    void emailService.sendTemplate("magic-link", email, { magicLinkUrl: url });
  },
}),
emailOTP({
  async sendVerificationOTP({ email, otp }) {
    void emailService.sendTemplate("otp", email, { otp });
  },
}),

You get four distinct passwordless paths — verification email, magic link, OTP, and passkey — without writing the token generation, storage, or expiry logic for any of them.

2FA and Passkeys

Two-factor auth and passkeys are one-line plugins:

plugins: [
  twoFactor(),
  passkey(),
  ...
]

The twoFactor() plugin adds TOTP-based 2FA. Users can enable it from their account settings. Sessions include a twoFactorVerified flag that route guards can check.

The passkey() plugin implements WebAuthn — users can register a biometric credential (Face ID, Touch ID, Windows Hello, hardware security key) and sign in without a password. Better Auth handles the WebAuthn challenge/response protocol and stores credentials in the passkeys table.

Both work across all template frameworks because they run on the server via the Hono API handler, not inside a framework-specific auth plugin.

API Keys

The apiKey() plugin from @better-auth/api-key adds a full API key management system:

  • Generate and revoke API keys scoped to a user or organization
  • Keys stored hashed in the api_keys table
  • Authenticate incoming requests by header without loading a session cookie

This is what powers the developer-facing API in templates that expose one. Instead of building your own key issuance system, you call auth.api.createApiKey(...) and get a working key with a single call.

The Lazy Singleton Pattern

One implementation detail worth calling out: auth is exported as a Proxy, not a raw object.

let _auth: Auth | null = null;

function getAuth(): Auth {
  if (_auth) return _auth;
  _auth = createAuth();
  return _auth;
}

export const auth = new Proxy({} as Auth, {
  get(_, key) {
    return getAuth()[key as keyof Auth];
  },
});

betterAuth() does I/O on initialization — it connects to the database adapter. In serverless environments (Vercel, Railway ephemeral), module-level I/O can happen at import time before the environment is ready. The Proxy defers initialization until the first method call, when the runtime is fully booted. This avoids cold-start crashes from database connections being established before env vars are loaded.

How the Handler is Wired

In the React Router + Hono flagship, auth requests are handled by a Hono catch-all route:

// routes/api.auth.$.ts (React Router server route)
import { auth } from "~/server/auth";
import { toHonoHandler } from "@better-auth/hono";

export const loader = toHonoHandler(auth);
export const action = toHonoHandler(auth);

Better Auth exposes a framework-agnostic handler that takes a Request and returns a Response. The toHonoHandler adapter bridges it to Hono's request context. For the Next.js variant, there is a toNextJsHandler equivalent. The auth config itself does not change.

What You Get vs Starting From Scratch

If you wired this up yourself from scratch, rough estimates:

Feature DIY time
Email/password + verification 4-8 hours
Google + GitHub OAuth 3-6 hours
Magic links 3-5 hours
OTP via email 2-4 hours
2FA (TOTP) 8-16 hours
Passkeys (WebAuthn) 16-40 hours
Organization multi-tenancy 24-40 hours
API keys 6-12 hours
Admin panel hooks 4-8 hours

That is 70-140 hours of auth work before you write a single line of your actual product. A better auth SaaS template like LaunchApp has all of it set up and tested on day one.

What You Still Own

Better Auth does not prevent you from customizing:

  • Add columns to users — the Drizzle adapter just reads what is there
  • Extend roles — add to defaultAc or define your own access control layer
  • Swap email templates — the emailService is injected, not hardcoded
  • Add OAuth providers — any provider Better Auth supports works in the same socialProviders block

The auth config is your code. It is not a dependency you consume at runtime through an API — it is in packages/auth/src/auth.ts and you own it.


If you want to see all of this running in a live template, check out the React Router 7 flagship template or the Next.js variant. Both ship this auth setup. The pricing page has the full bundle option if you want all framework variants at once.

Want to follow along? Browse all posts or follow @launchappdev on X.