Guide

Invitation Flow

Invite users to join organizations with email notifications

UNuxt provides a complete invitation system for adding members to organizations with beautiful email notifications and seamless acceptance flow.

How invitations work

The invitation flow consists of three main steps:

  1. Admin invites a user by email and assigns a role
  2. Email is sent with an invitation link
  3. User accepts the invitation and joins the organization

Send an invitation

Organization owners and admins can invite new members:

composables/useOrganization.ts
const { inviteMember } = useOrganization()

async function sendInvite(email: string, role: 'member' | 'admin') {
  try {
    await inviteMember(email, role)
    toast.add({
      title: 'Success',
      description: 'Invitation sent successfully',
      color: 'success',
    })
  } catch (error) {
    toast.add({
      title: 'Error',
      description: error.message,
      color: 'error',
    })
  }
}

From the UI

The organization page includes a built-in invitation modal:

pages/org/[id].vue
<UButton @click="showInviteModal = true">
  <UIcon name="i-lucide-user-plus" class="mr-2" />
  Invite Member
</UButton>

Accept an invitation

Users receive an email with an invitation link pointing to /auth/accept-invite/[token].

User experience

When users click the invitation link:

If not logged in:

  • Prompted to sign in or create an account
  • Redirected back to accept invitation after authentication
  • Seamlessly joins the organization

If logged in:

  • Automatically accepts the invitation
  • Redirected to the organization page
  • See success message

Acceptance page

The acceptance page handles all edge cases:

pages/auth/accept-invite/[token].vue
<script setup lang="ts">
const { $authClient } = useNuxtApp()
const token = computed(() => route.params.token as string)

async function acceptInvitation() {
  const result = await $authClient.organization.acceptInvitation({
    invitationId: token.value,
  })

  if (result.error) {
    // Handle errors: expired, invalid, already accepted
    error.value = result.error.message
  } else {
    // Success! Redirect to organization
    navigateTo(`/org/${result.data.organization.id}`)
  }
}
</script>

Handle edge cases

The invitation system handles common scenarios gracefully:

Expired invitations

Invitations expire based on the expiresAt field in the database:

if (errorMessage.includes('expired')) {
  error.value = 'This invitation has expired. Please request a new invitation.'
}

Already accepted

Users can't accept the same invitation twice:

if (errorMessage.includes('already accepted') || errorMessage.includes('already a member')) {
  error.value = 'You are already a member of this organization.'
}

Invalid invitations

Cancelled or non-existent invitations are rejected:

if (errorMessage.includes('not found')) {
  error.value = 'This invitation is invalid or has been cancelled.'
}

Manage invitations

List pending invitations

Admins can view all pending invitations for an organization:

server/api/org/[id].get.ts
const invitations = await db
  .select()
  .from(invitations)
  .where(
    and(
      eq(invitations.organizationId, orgId),
      eq(invitations.status, 'pending')
    )
  )

Cancel an invitation

Admins can cancel pending invitations before they're accepted:

composables/useOrganization.ts
const { cancelInvitation } = useOrganization()

async function cancel(invitationId: string) {
  await cancelInvitation(invitationId)
  // Invitation is deleted from database
}

Customize invitation emails

Invitation emails are sent automatically using the email system. Customize the template in packages/email/src/templates/organization-invitation.ts:

const content = `
  <p>Hi there,</p>
  <p><strong>${invitedBy}</strong> has invited you to join <strong>${organizationName}</strong>.</p>
  <p>You've been invited as a <strong>${role}</strong>.</p>
  <p>Click the button below to accept the invitation:</p>
`

Configure invitation URLs

Invitation URLs are generated automatically and point to your frontend:

packages/auth/src/server.ts
sendInvitationEmail: async ({ email, organization, invitedBy, invitation }) => {
  const baseURL = process.env.BETTER_AUTH_URL || "http://localhost:3000"
  const inviteUrl = `${baseURL}/auth/accept-invite/${invitation.id}`

  await sendEmail(
    organizationInvitationEmail({
      email,
      organizationName: organization.name,
      invitedBy: invitedBy.name || invitedBy.email,
      inviteUrl,
      role: invitation.role,
    })
  )
}

Set invitation expiration

Control how long invitations remain valid by setting the expiration when creating invitations:

const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days from now

await db.insert(invitations).values({
  id: generateId(),
  organizationId: org.id,
  email: 'user@example.com',
  role: 'member',
  status: 'pending',
  expiresAt,
  inviterId: currentUser.id,
})
Default expiration: Better Auth's organization plugin automatically sets a 7-day expiration for invitations.

Check invitation status

Query the invitation status programmatically:

server/api/invitation/[id]/status.get.ts
export default defineEventHandler(async (event) => {
  const invitationId = getRouterParam(event, 'id')

  const invitation = await db.query.invitations.findFirst({
    where: eq(invitations.id, invitationId),
  })

  if (!invitation) {
    return { status: 'not_found' }
  }

  if (invitation.status === 'accepted') {
    return { status: 'accepted' }
  }

  if (new Date() > invitation.expiresAt) {
    return { status: 'expired' }
  }

  return { status: 'pending' }
})

Invitation database schema

Invitations are stored in the invitations table:

packages/db/src/schema/members.ts
export const invitations = pgTable("invitations", {
  id: text("id").primaryKey(),
  organizationId: text("organization_id")
    .references(() => organizations.id, { onDelete: "cascade" })
    .notNull(),
  email: text("email").notNull(),
  role: text("role").notNull().default("member"),
  status: text("status").notNull().default("pending"),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  inviterId: text("inviter_id")
    .references(() => users.id)
    .notNull(),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
})

Testing invitations

Manual testing

  1. Create an organization as User A
  2. Invite a new email address
  3. Check console for invitation URL (if SMTP not configured)
  4. Open URL in incognito window
  5. Create account or sign in
  6. Verify user joins organization

E2E tests

UNuxt includes E2E tests for the invitation flow:

tests/e2e/invitation.spec.ts
test('should show not logged in state', async ({ page }) => {
  await page.goto('/auth/accept-invite/mock-token-123')

  await expect(page.getByText("You've Been Invited!")).toBeVisible()
  await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible()
})

Resend invitations

To resend an invitation, delete the old one and create a new one:

async function resendInvitation(invitationId: string) {
  // Get the old invitation details
  const oldInvitation = await db.query.invitations.findFirst({
    where: eq(invitations.id, invitationId),
  })

  // Delete the old invitation
  await cancelInvitation(invitationId)

  // Send a new invitation
  await inviteMember(oldInvitation.email, oldInvitation.role)
}

Next steps

Copyright © 2026