Organizations
Multi-tenant workspaces with members, invitations, roles, settings, billing and organization-scoped data, built on the Better Auth organization plugin.
Organizations let several users share a workspace with its own members, projects and subscription. Launch
Now builds them on the Better Auth organization plugin and adds pages, server actions and guards on top.
Every organization has a unique slug that appears in its URL: /orgs/acme.
How it works
The organization plugin creates the organization, member and invitation tables and stores the
active organization on the session (session.activeOrganizationId). Launch Now reads those tables with
Drizzle for pages, and writes to them through server actions that check the caller's role first.
| File | Role |
|---|---|
lib/auth.ts | Plugin configuration: organization limit, invitation email and organization hooks. |
lib/organization-server.ts | Server helpers: getOrganizationForPage, requireMembership, canManageOrg, revalidateOrganization, listOrganizationMembers. |
lib/organization-actions.ts | Server actions for settings, members and invitations. |
lib/slug.ts | slugify, slugSchema, reserved slugs and the 48 character limit. |
lib/org-project-config.ts, lib/org-project-actions.ts | The organization projects resource. |
app/(app)/orgs/* | Organization list, overview, projects and settings pages. |
app/accept-invitation/[invitationId]/* | Public invitation page. |
features/organization/* | Organization UI: list, overview, settings forms, members table, billing. |
features/org-projects/* | Projects table and form sheet. |
components/layouts/sidebar/organization-switcher.tsx | Switcher in the sidebar that sets the active organization. |
Routes
| Route | Content |
|---|---|
/orgs | Organizations the user belongs to, and the create dialog (/orgs?new=1 opens it). |
/orgs/[orgSlug] | Overview. |
/orgs/[orgSlug]/org-projects | Projects shared by the organization. |
/orgs/[orgSlug]/settings | General: logo, name and URL. |
/orgs/[orgSlug]/settings/members | Members and pending invitations. |
/orgs/[orgSlug]/settings/security | Security policies (placeholders marked "Coming soon"). |
/orgs/[orgSlug]/settings/billing | Organization plan, invoices and payment method. |
/orgs/[orgSlug]/settings/danger | Leave or delete the organization. |
/accept-invitation/[invitationId] | Accept or decline an invitation. |
Plugin configuration
The plugin is configured in lib/auth.ts:
organization({
organizationLimit: 5,
async sendInvitationEmail({ invitation, organization, inviter }) {
const inviteUrl = `${env.BETTER_AUTH_URL}/accept-invitation/${encodeURIComponent(invitation.id)}`
// ... builds the HTML and calls sendEmail()
},
organizationHooks: {
async afterCreateOrganization({ organization, user }) { /* ... */ },
async beforeUpdateOrganization({ organization, member }) { /* ... */ },
async beforeDeleteOrganization({ organization }) { /* ... */ },
async afterCreateInvitation({ invitation, inviter, organization }) { /* ... */ },
async afterAcceptInvitation({ user, organization }) { /* ... */ },
},
}),organizationLimit: 5caps how many organizations one user can create. Change the number to fit your product.sendInvitationEmailsends the invitation link withsendEmailfromlib/email.ts. The organization and inviter names are HTML-escaped.- The hooks send notifications (new organization for admins, invitation received, member joined), and
enforce two rules even when the Better Auth API is called directly:
- Only the owner can change the organization slug.
- An organization with an
activeortrialingsubscription can't be deleted.
Creating an organization
Users create organizations in two places:
- The onboarding flow at
/auth/onboarding, right after sign-up. - The create dialog on
/orgs(features/organization/organizations-list.tsx).
Both call the plugin from the client and make the new organization active:
const { data, error } = await authClient.organization.create({
name: name.trim(),
slug: effectiveSlug,
})
// ...
await authClient.organization.setActive({ organizationId: data.id })The creator becomes the owner.
Slugs
lib/slug.ts defines what a valid slug is:
export const SLUG_MAX_LENGTH = 48
/** Top-level /orgs children and generic names that must never be used as a slug. */
const RESERVED_SLUGS = new Set(["new", "create", "settings", "admin", "api"])
export const slugSchema = z
.string()
.min(2, "Use at least 2 characters")
.max(SLUG_MAX_LENGTH, `Use at most ${SLUG_MAX_LENGTH} characters`)
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Use lowercase letters, numbers and single hyphens, without a hyphen at the start or end"
)
.refine((slug) => !RESERVED_SLUGS.has(slug), "This URL is reserved")slugify("Café & Co Paris!") returns "cafe-co-paris". The create dialog derives the slug from the
name until the user edits it.
The useSlugAvailability hook (features/organization/use-slug-availability.ts) checks the slug as the
user types. It validates the format locally, then, after a 350 ms debounce, calls the
checkSlugAvailability server action, which re-validates and looks for an existing organization with
that slug. The hook returns a state of idle, checking, available or unavailable with a reason.
If you add a top-level route under /orgs/ (for example /orgs/import), add its segment to
RESERVED_SLUGS so no organization can take it.
Active organization
Better Auth stores the active organization on the session. The sidebar switcher reads it with
authClient.useActiveOrganization() and changes it with authClient.organization.setActive(). The
active organization is set when a user creates an organization or accepts an invitation, and cleared
(organizationId: null) when they leave or delete it. The deleteOrganization and leaveOrganization
actions also clear activeOrganizationId on the affected sessions in the database.
Organization pages don't depend on the active organization: they load the organization from the
[orgSlug] segment. Use the active organization for defaults, such as which workspace to open.
Roles and permissions
Launch Now uses the three default roles of the Better Auth organization plugin:
| Action | Owner | Admin | Member |
|---|---|---|---|
| View the organization and its members | Yes | Yes | Yes |
| Create and edit organization projects | Yes | Yes | Yes |
| Delete organization projects | Yes | Yes | No |
| Change name and logo | Yes | Yes | No |
| Change the URL (slug) | Yes | No | No |
| Invite, resend and cancel invitations | Yes | Yes | No |
| Remove members | Yes (anyone but the owner) | Only member role | No |
| Change a member's role | Yes | No | No |
| Manage billing (checkout, portal, cancel) | Yes | Yes | No |
| See invoices | Yes | Yes | No |
| Leave the organization | No | Yes | Yes |
| Delete the organization | Yes | No | No |
Invitations and role changes can only assign member or admin (the assignableRole enum in
lib/organization-actions.ts). Ownership can't be transferred from the UI, and nobody can change their
own role or remove themselves.
The table is enforced on the server by requireMembership() and repeated in the UI only to hide
controls. features/organization/organization-members.tsx uses canRemove and canChangeRole helpers
that mirror the server rules.
Protecting organization pages
Every page under app/(app)/orgs/[orgSlug]/ goes through getOrganizationForPage() in the
organization layout:
export default async function RouteLayout({
children,
params,
}: LayoutParams<OrgSlugParams>) {
const orgSlug = (await params)?.orgSlug ?? ""
// 404s for unknown orgs and non-members; cached for the rest of the request.
await getOrganizationForPage(orgSlug)
return <>{children}</>
}getOrganizationForPage(slug) redirects to /auth/signin without a session, and calls notFound()
when the organization doesn't exist or the user isn't a member. A non-member can't tell whether an
organization exists. It is wrapped in React cache, so pages call it again for free to get the data:
export default async function RoutePage({
params,
}: PageParams<{ orgSlug: string }>) {
const { orgSlug } = await params
const { organization, role } = await getOrganizationForPage(orgSlug)
return (
// ...
<OrgProjectTable
organizationId={organization.id}
orgSlug={organization.slug}
canDelete={canManageOrg(role)}
/>
)
}It returns organization, membership, role ("owner" | "admin" | "member") and userId.
canManageOrg(role) is true for owners and admins.
Server actions
Actions receive an organizationId from the client, so never trust it. Check the membership first with
requireMembership(), which throws an ActionError when the user isn't a member or lacks one of the
allowed roles:
const updateOrganizationName = authAction
.inputSchema(
z.object({
organizationId: z.string().min(1),
name: z.string().trim().min(1).max(64),
})
)
.action(async ({ parsedInput: { organizationId, name }, ctx: { user } }) => {
await requireMembership(organizationId, user.id, ["owner", "admin"])
await db
.update(organizationTable)
.set({ name })
.where(eq(organizationTable.id, organizationId))
await revalidateOrganization(organizationId)
})Omit the roles argument to allow any member. After a write, call revalidateOrganization(id): it
revalidates /orgs and every page under /orgs/[slug].
Members and invitations
The members page loads members and pending, unexpired invitations on the server with
listOrganizationMembers(), so revalidatePath refreshes the list after every action.
| Action | Server action | Notes |
|---|---|---|
| Invite | inviteMember | Owner or admin. Rejects emails that are already members. Goes through auth.api.createInvitation so the invitation email is sent. |
| Resend | resendInvitation | Calls createInvitation again with resend: true. |
| Cancel | cancelInvitation | Sets the invitation status to canceled. |
| Change role | updateMemberRole | Owner only. Notifies the member (org.role_changed). |
| Remove | removeMember | Notifies the removed user (org.member_removed). |
| Leave | leaveOrganization | Not allowed for the owner. Notifies owners and admins (org.member_left). |
If the invited email already has an account, the afterCreateInvitation hook also sends them an in-app
notification.
Accepting an invitation
The invitation email links to /accept-invitation/[invitationId]. The page
(app/accept-invitation/[invitationId]/page.tsx) reads the invitation from the database, because Better
Auth's getInvitation only answers the invitee, and handles each case:
- Not found, accepted, canceled, declined or expired: shows a message and a link to
/orgs. - Signed out: shows Sign in to accept and Create account, both with
callbackURL=/accept-invitation/[invitationId]so the user comes back here after signing in. The invited email is shown masked. - Signed in with another email: offers to switch accounts, which signs out and returns to sign-in with the same callback.
- Signed in as the invitee: shows Accept and Decline.
Accepting calls authClient.organization.acceptInvitation, makes the organization active and redirects
to /orgs/[orgSlug]. Declining calls authClient.organization.rejectInvitation and redirects to /orgs.
Organization settings
General
features/organization/organization-general-settings.tsx edits the logo (updateOrganizationLogo), the
name (updateOrganizationName) and the URL (updateOrganizationSlug). Changing the URL is owner-only,
uses the same availability check as creation, and revalidates both the old and new paths. Links to the
old URL stop working.
Security
features/organization/organization-security.tsx lists three policies (require 2FA, session timeout,
IP allowlist) as disabled switches marked "Coming soon". They are UI placeholders with no server logic.
Build them, or remove the page along with its links in components/layouts/nav-configs/organization.tsx
and features/organization/organization-overview.tsx.
Billing
Each organization can have its own subscription. app/(app)/orgs/[orgSlug]/settings/billing/page.tsx
loads the plans and the organization's plan with getOrgPlan(organization.id), and only fetches invoices
for owners and admins. OrganizationBilling passes customerType="organization" and
referenceId={organizationId} to the Stripe client, and sends the plan's seats limit as the seat
count at checkout. Plain members see the plan but can't change it.
See Billing for how organization subscriptions work.
Danger zone
features/organization/organization-danger.tsx offers:
- Leave organization for admins and members.
- Delete organization for the owner, who must type the organization name to confirm. The
deleteOrganizationaction (anonDemoAction) refuses while a subscription isactiveortrialing, then, in a transaction, clears the active organization on sessions, deletes the organization's subscription rows and the organization. Members and invitations are deleted by cascade.
Organization projects
Organization projects are an example of data owned by an organization instead of a user. Use them as the template for your own organization-scoped features.
drizzle/auth-schema.tsdefines theorg_projectstable withorganizationIdanduserId(the creator), both deleted by cascade.lib/org-project-config.tsdescribes the fields (name,description,status,dueDate) and builds the Zod schemas.lib/org-project-actions.tsexposeslistOrgProjects,getOrgProject,createOrgProject,updateOrgProject,deleteOrgProjectanddeleteOrgProjects.features/org-projects/holds the table and the create and edit sheet.
Every action checks the membership and scopes every query by organizationId, so a user can't read or
change another organization's rows by passing a different ID:
const deleteOrgProject = nonDemoAction
.inputSchema(organizationInput.extend({ id: z.string() }))
.action(async ({ ctx, parsedInput: { organizationId, id } }) => {
await requireMembership(organizationId, ctx.user.id, ["owner", "admin"])
const [deleted] = await db
.delete(orgProjects)
.where(
and(
eq(orgProjects.id, id),
eq(orgProjects.organizationId, organizationId)
)
)
.returning({ id: orgProjects.id })
if (!deleted) throw new ActionError("Project not found")
await revalidate(organizationId)
return deleted
})Any member can list, create and update projects; only owners and admins can delete them.
Generating an organization resource
The resource generator can scaffold a new organization-scoped resource with the same structure. Run it
interactively, or pass a JSON spec with "scope": "organization":
pnpm resource:generate --spec path/to/spec.jsonOrganization resources are placed under /orgs/[orgSlug]/ by default.
Customization
- Change the organization limit: edit
organizationLimitinlib/auth.ts. - Change the invitation email: edit
sendInvitationEmailinlib/auth.ts. - Reserve more slugs: add them to
RESERVED_SLUGSinlib/slug.ts. - Change who can do what: update the roles passed to
requireMembership()inlib/organization-actions.tsandlib/org-project-actions.ts, then the matching UI helpers (canManageOrg,canRemove,canChangeRole) so hidden buttons match the server.