Hype StackHypeStack

Stack

Better Auth SaaS Template

Owning your session table is the easy half. Invitations, org switching, and role checks are the half that fills your repository.

Better Auth keeps the session table in your database. That avoids a per-seat auth bill and an external user store to synchronize, while allowing direct joins between users and application tables.

The trade-off is that flows handled by a hosted provider become code in your repository. A better auth saas template is most useful when it already implements those flows, because setting up the auth library is only one part of multi-tenant SaaS authentication.

So this page is about the files. Which tables land in your schema, which defaults are already switched on before you notice them, and how much of your codebase ends up knowing the name of your auth provider.

Eleven tables, and where the multi-tenancy hides

The starter adds seven models for the auth engine and four for the application:

ModelPurpose
User, AccountIdentity, plus one row per linked credential or OAuth provider
SessionLive sessions, including activeOrganizationId
VerificationShort-lived tokens for email verification and password resets
Organization, MemberTenants and memberships, from the organization plugin
InvitationDeclared so the plugin's schema matches the database, but unused by the app
OrganizationSettings, UserSettingsPer-tenant and per-user preferences
OrganizationInvitationThe invitation flow the app actually runs
AdminPlatform staff, separate from your tenants' roles

Two of them get their own section further down, because Invitation and Admin are both there for reasons that are not obvious. Session.activeOrganizationId deserves attention first because it shapes most tenant-scoped queries in the app.

Which tenant you are currently acting as lives on the session row. Not in a React context, not in a URL segment, not in a header the client sets. Follow one request through and the consequence shows up at the end:

How a request learns which tenant it is in

  1. Signup creates the session

    A session hook sets activeOrganizationId to your first membership as the row is written, which is what makes a fresh signup land somewhere instead of nowhere.

  2. Switching organizations is a write

    Picking a different tenant updates the session row on the server. There is no client state to keep in sync, and a stale browser tab cannot disagree with the backend about who you are acting as.

  3. Your feature routes read it from the session

    Middleware resolves the session and the tenant arrives on the context as c.var.organizationId. The routes you write for your own product take no organization id from the client, so there is none to validate.

  4. The organization routes are the exception, and they are the ones to read

    Managing an organization needs an id in the path, so /organizations/:organizationId exists and takes a client-supplied value. Those handlers are where the permission middleware earns its place, and the plain GET is deliberately readable by any signed-in user. Check that against your own privacy requirements before launch.

    Read these first

The default path gets its tenant from the session. Routes that accept an organization id still require explicit permission checks and should be audited.

Decisions the starter already made for you

These defaults ship in the config and should be reviewed before launch:

Inbox gate

Signup ends on a check your inbox screen.

Auto sign-in

emailAndPassword is on with autoSignIn set to true, so a new user lands inside the app instead of in their mail client.

Verification required

Email verification gates the signup form.

Gate left open

requireEmailVerification is false. Verification is wired and the emails send through Resend, but the gate remains open until you enable it. Review that default before exposing public signup.

Providers preconfigured

The usual social providers are configured.

Google only

Google is the only configured provider. Adding GitHub or Apple requires provider credentials and a config change.

Invite only

Only invited people can create a tenant.

Anyone signed in

The organization plugin runs with allowUserToCreateOrganization set to true, so any signed-in user can create one. Change that flag if your product is invite-only.

Some of these defaults are convenient during development but may not fit a launched product. Review each one before release.

Invitations skip the plugin, on purpose

The organization plugin ships its own invitation table and the starter declares it, then does not use it. Invites run through the app's own OrganizationInvitation model instead, with statuses pending, accepted, revoked, cancelled, and expired, a Resend email that links to /login?invitation_token=..., and a websocket push so an invitee who already has an account sees it without refreshing.

That is a deliberate trade: more code in your repository in exchange for an invitation lifecycle you can modify. For example, a resend rate limit can be added directly to the application flow. The plugin's table stays declared because the schema has to match what the plugin expects, which leaves an unused model in the schema.

Where the engine's vocabulary stops

Better Auth calls the top organization role owner. The application calls it admin. The translation is kept at the identity boundary:

ts
// apps/backend/src/libs/identity/mappers.ts

// Better Auth's organization plugin makes the org creator an "owner". The domain
// contract only models admin/member (parity with the other starters), and an
// owner has the same capabilities as an admin, so collapse it to "admin" right
// here at the engine boundary. Nothing past this file should ever see "owner".
function normalizeRoleSlug(slug: string): string {
  return slug === "owner" ? "admin" : slug;
}

Past that file the code is written against DomainUser and DomainMembership rather than anything a vendor named, and the way to check how far that actually goes is to diff this starter against its WorkOS twin. Of the roughly 217 files in the pack, 136 are byte-identical between the two. What differs is exactly what you would expect to differ: the auth routes, the modules that fetch a user or a membership, the engine config, and the two auth hooks. One version reads a Postgres row and the other calls an API, so those files necessarily differ.

What does survive the swap is the part that is expensive to rewrite. The CASL permission strings like manage:all and read:user, the role presets they map to, the domain types every route signature is written in, and the admin app's screens are the same on both sides. This limits an engine swap mainly to the identity layer rather than the authorization model, though the effort still depends on later customizations.

Grep the pack for better-auth and you get three files: libs/auth/auth.ts where the engine is configured, features/auth/constants/index.ts for the session cookie name, and the test bootstrap. Keeping those references out of route handlers reduces the amount of provider-specific code involved in a future change.

Grep the pack for better-auth and three files come back. None of them is a route handler.

Which engine fits your team

Self-hosted auth is partly a staffing decision. Your team becomes responsible for operating the user store, while a hosted provider takes on more of that work.

Who should operate and support the identity system?

Better Auth

Take it when you want the rows in your own database.

  • Sessions and users are tables you can JOIN against, so a report about signups is a query rather than an API integration
  • No per-user pricing tier to cross, with availability tied to infrastructure you operate
  • The flows are in your repository, which means they are yours to fix and yours to keep working
WorkOS

Take the twin when identity is somebody else's job.

  • Same permission strings, same domain types, same admin screens: the identity layer is what gets rewritten
  • Enterprise SSO and directory sync are their product, so the first customer who demands SAML is a purchase rather than a project
  • The trade is a service you do not run, a bill that scales with users, and user rows you reach over HTTP

Both starters use the same domain API surface, which reduces the scope of changing providers later.

The mapper matters because auth is selected before many teams know their eventual compliance and enterprise requirements. This boundary reduces the cost of changing engines later. If SAML becomes a requirement, the expected work is concentrated in the identity layer, but any provider-specific customizations added elsewhere would increase the scope.

Below is the starter running, and what lands in your repository when you install it, so you can run that diff and count the tables yourself rather than taking my word for either.

See it running

Templates are curated project starters built on this stack: a layout, feature packs, a custom theme, and bonus pages. The previews below are recordings of the real apps.

Two ways to install features

Same features underneath, different starting point. Either command resolves what the packs depend on, copies the source into your repository, and merges the Prisma schema.

Ready-made

Take a template

A landing page, a design system, a themed layout, and the features already wired into it. Rebrand it, put your product in the middle, ship.

$npx @hype-stack/cli template
Browse templates
From scratch

$ hype-stack compose

Auth

Compose your own design

Your design and your choices, without rebuilding auth, billing, or notifications. Tick the packs you want and the CLI wires them into the open-source starter.

$npx @hype-stack/cli compose
Browse packs

Inside the stack

What each pack gives you

Source code, not a dependency. Every pack lands in your repository across the surfaces the feature touches.

Authentication, organizations, roles, sessions, and a full admin app, powered by Better Auth on your own Postgres.

  • Email & Google login
  • Organizations & members
  • Roles & permissions
  • Admin app & dashboard

Questions, answered

SaaS Starter (Better Auth) on the free core: eleven Prisma models covering users, sessions, accounts, organizations and memberships, the Hono integration, sign-in and sign-up screens, the invitation flow, and role handling in the admin app.

More stacks

Turn your ideas into
Real applications.

Start free and own every line you ship. When you want more, one All-Access license unlocks every premium pack and template for a year.

All premium packsEvery template12 months of updates
Get All-Access