Launch NowDocs

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.

FileRole
lib/auth.tsPlugin configuration: organization limit, invitation email and organization hooks.
lib/organization-server.tsServer helpers: getOrganizationForPage, requireMembership, canManageOrg, revalidateOrganization, listOrganizationMembers.
lib/organization-actions.tsServer actions for settings, members and invitations.
lib/slug.tsslugify, slugSchema, reserved slugs and the 48 character limit.
lib/org-project-config.ts, lib/org-project-actions.tsThe 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.tsxSwitcher in the sidebar that sets the active organization.

Routes

RouteContent
/orgsOrganizations the user belongs to, and the create dialog (/orgs?new=1 opens it).
/orgs/[orgSlug]Overview.
/orgs/[orgSlug]/org-projectsProjects shared by the organization.
/orgs/[orgSlug]/settingsGeneral: logo, name and URL.
/orgs/[orgSlug]/settings/membersMembers and pending invitations.
/orgs/[orgSlug]/settings/securitySecurity policies (placeholders marked "Coming soon").
/orgs/[orgSlug]/settings/billingOrganization plan, invoices and payment method.
/orgs/[orgSlug]/settings/dangerLeave or delete the organization.
/accept-invitation/[invitationId]Accept or decline an invitation.

Plugin configuration

The plugin is configured in lib/auth.ts:

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: 5 caps how many organizations one user can create. Change the number to fit your product.
  • sendInvitationEmail sends the invitation link with sendEmail from lib/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 active or trialing subscription 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:

features/organization/organizations-list.tsx
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:

lib/slug.ts
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:

ActionOwnerAdminMember
View the organization and its membersYesYesYes
Create and edit organization projectsYesYesYes
Delete organization projectsYesYesNo
Change name and logoYesYesNo
Change the URL (slug)YesNoNo
Invite, resend and cancel invitationsYesYesNo
Remove membersYes (anyone but the owner)Only member roleNo
Change a member's roleYesNoNo
Manage billing (checkout, portal, cancel)YesYesNo
See invoicesYesYesNo
Leave the organizationNoYesYes
Delete the organizationYesNoNo

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:

app/(app)/orgs/[orgSlug]/layout.tsx
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:

app/(app)/orgs/[orgSlug]/org-projects/page.tsx
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:

lib/organization-actions.ts
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.

ActionServer actionNotes
InviteinviteMemberOwner or admin. Rejects emails that are already members. Goes through auth.api.createInvitation so the invitation email is sent.
ResendresendInvitationCalls createInvitation again with resend: true.
CancelcancelInvitationSets the invitation status to canceled.
Change roleupdateMemberRoleOwner only. Notifies the member (org.role_changed).
RemoveremoveMemberNotifies the removed user (org.member_removed).
LeaveleaveOrganizationNot 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 deleteOrganization action (a nonDemoAction) refuses while a subscription is active or trialing, 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.
Deleting an organization deletes its projects and memberships. Cancel the subscription first: the action refuses to delete an organization with an active subscription.

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.ts defines the org_projects table with organizationId and userId (the creator), both deleted by cascade.
  • lib/org-project-config.ts describes the fields (name, description, status, dueDate) and builds the Zod schemas.
  • lib/org-project-actions.ts exposes listOrgProjects, getOrgProject, createOrgProject, updateOrgProject, deleteOrgProject and deleteOrgProjects.
  • 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:

lib/org-project-actions.ts
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.json

Organization resources are placed under /orgs/[orgSlug]/ by default.

Customization

  • Change the organization limit: edit organizationLimit in lib/auth.ts.
  • Change the invitation email: edit sendInvitationEmail in lib/auth.ts.
  • Reserve more slugs: add them to RESERVED_SLUGS in lib/slug.ts.
  • Change who can do what: update the roles passed to requireMembership() in lib/organization-actions.ts and lib/org-project-actions.ts, then the matching UI helpers (canManageOrg, canRemove, canChangeRole) so hidden buttons match the server.

Next steps