---
title: "Workspaces"
description: "Multi-tenant workspaces (the organization plugin) — the active workspace as a JWT claim, useOrganization, WorkspaceSettings, invitations end-to-end, and the personal workspace."
canonical_url: "https://nuxt-backend.dev/docs/auth/workspaces"
---
# Workspaces

> Multi-tenant workspaces (the organization plugin) — the active workspace as a JWT claim, useOrganization, WorkspaceSettings, invitations end-to-end, and the personal workspace.

Workspaces are Better Auth's organization plugin, built into `setupAuth`. Every signed-in user gets a **personal workspace** on first sign-in and set active, so the session always has an active workspace — and because billing follows the tenant (`billTo: 'organization'` is the default), the active workspace is what owns the subscription and credits.

## The active workspace is a claim

The active workspace rides on the Convex JWT as `activeOrganizationId`. Server-side, [authorization](https://nuxt-backend.dev/docs/auth/access/roles) reads it without a database round-trip; client-side, switching workspaces refreshes the token and re-authenticates the live connection, so every workspace-scoped query re-runs against the new tenant.

```ts [backend/auth.ts]
export const { authComponent, createAuth, getAuthUser, listWorkspaces, listWorkspaceMembers } = setupAuth(components, query, {
  email,
  // Options are the organization plugin's, plus:
  organization: {
    invitationPath: '/accept-invitation', // where emailed invitation links land (default)
  },
})
```

`setupAuth` also returns two ready-made queries the scaffold re-exports and the [agent tools](https://nuxt-backend.dev/docs/agents/mcp/built-in-tools) call: `listWorkspaces` (the caller's workspaces, with role and which one is active) and `listWorkspaceMembers` (members of the active — or a given — workspace).

## `useOrganization()`

The auto-imported composable is the whole client-side surface — state, switching, creation, and the full invitation flow:

```vue
<script setup lang="ts">
const { organizations, current, member, role, members, isLoading, setActive, create } = useOrganization()
</script>

<template>
  <select :value="current?.id" @change="setActive(($event.target as HTMLSelectElement).value)">
    <option v-for="workspace in organizations" :key="workspace.id" :value="workspace.id">
      {{ workspace.name }}
    </option>
  </select>
  <p>You are {{ role }} of “{{ current?.name }}” ({{ members.length }} members)</p>
</template>
```

<field-group>
<field name="organizations" type="ComputedRef<Workspace[]>">

Every workspace the user belongs to (`id`, `name`, `slug`, `logo`, `createdAt`, `metadata`).

</field>

<field name="current" type="ComputedRef<ActiveWorkspace | null>">

The active workspace with its `members` and pending `invitations`.

</field>

<field name="member / role" type="ComputedRef<WorkspaceMember | null> / ComputedRef<string | null>">

The user's membership row in the active workspace and their role *within it* (`owner` / `admin` / `member` or custom).

</field>

<field name="setActive(organizationId)" type="Promise<void>">

Switch the active workspace: sets it on the session, force-refreshes the Convex token, and re-authenticates the live connection.

</field>

<field name="create({ name, slug, logo? })">

Create a workspace (users can own several). The new workspace becomes active, so the claim is refreshed like `setActive`.

</field>

<field name="leave(organizationId?)">

Leave a workspace (defaults to the active one); refreshes the claim.

</field>

<field name="invite({ email, role?, organizationId? })">

Invite someone to the active workspace (`role` defaults to `'member'`). They receive an accept-link email.

</field>

<field name="acceptInvitation(id, { activate? }) · declineInvitation(id) · cancelInvitation(id)">

Both sides of an invitation. `activate: true` also switches to the joined workspace.

</field>

<field name="getInvitation(id) · listReceivedInvitations()">

Details of one invitation (workspace + inviter, for a custom accept page) and every invitation addressed to the signed-in user.

</field>
</field-group>

Each action rejects with the reason when the server refuses it, such as a taken slug or an invitation for someone already in the workspace, so a `catch` sees every failure.

Anything beyond — removing a member, updating a member's role — lives on the fully-typed client: `useAuth().client.organization.*`.

## `<WorkspaceSettings>`

The `/settings` page is this one component: workspace list + create + switch, the billing summary (plan / period / cancel), and the credits balance + top-up — headless markup on `data-settings` hooks, every region a slot receiving the full state (`workspace`, `billing`, `credits`, `packs`, `planName`, `periodEnd`, `pending`, `createWorkspace`, `switchWorkspace`, `cancelPlan`, `buyPack`, `refreshCredits`, …).

```vue [app/pages/settings.vue]
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>

<template>
  <WorkspaceSettings :sections="['workspaces', 'credits']" @workspace-switched="id => console.log('now on', id)">
    <template #create="{ createWorkspace, pending }">
      <MyForm :busy="pending === 'create'" @submit="createWorkspace" />
    </template>
  </WorkspaceSettings>
</template>
```

## `<OrganizationBoundary>`

Gate a page on having an active workspace. The default slot receives `{ workspace }`; `fallback` renders when the user has none (the place for a picker or a create form), `placeholder` while loading. With the default personal workspace, `fallback` only shows for users created before the feature or when `personal` is disabled.

```vue
<template>
  <OrganizationBoundary v-slot="{ workspace }">
    <ProjectList :organization-id="workspace.id" />
    <template #fallback>
      <CreateWorkspaceForm />
    </template>
  </OrganizationBoundary>
</template>
```

## Invitations end-to-end

1. A member calls `useOrganization().invite({ email, role })`.
2. The invitee receives the `invite` [email template](https://nuxt-backend.dev/docs/email/templates) with an accept link — `{SITE_URL}{invitationPath}?id=<invitationId>` (`invitationPath` defaults to `/accept-invitation`).
3. The link lands on the built-in `/accept-invitation` page (mounted behind the `auth` middleware, so signing in first is enforced), which renders `<AcceptInvitation>` — workspace + inviter details with accept / decline buttons.
4. Accepting joins the workspace (optionally activating it); declining rejects the invitation.

Beyond `invite`, `useOrganization()` covers both sides of the flow:

```ts
const {
  acceptInvitation,        // (invitationId, { activate? }) — join, optionally set active
  declineInvitation,       // (invitationId)
  cancelInvitation,        // (invitationId) — inviter side, revoke a pending invite
  getInvitation,           // (invitationId) — details for a custom accept page, or null once it is gone
  listReceivedInvitations, // invitations addressed to the signed-in user
} = useOrganization()
```

### Customizing the flow

- **Email** — override the `invite` [template](https://nuxt-backend.dev/docs/email/templates) on `setupEmail` to restyle without replacing the transport, or supply the plugin's own `organization.sendInvitationEmail` to take over delivery entirely. Each invitation is sent once per issuance; a resend goes out again.
- **Link path** — `organization: { invitationPath: '/join' }` changes where emailed links point.
- **Page** — the module mounts it at `/accept-invitation`; the module option `pages.acceptInvitation` moves it, and an app page at its path replaces it.

A custom accept page is the component behind the `auth` middleware — it reads `?id=` from the URL itself:

```vue [app/pages/join.vue]
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>

<template>
  <AcceptInvitation />
</template>
```

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['nuxt-backend'],
  backend: { pages: { acceptInvitation: '/join' } },
})
```

```ts [backend/auth.ts]
setupAuth(components, query, {
  email,
  organization: { invitationPath: '/join' },
})
```

<warning>

`invitationPath` (Convex side, what emails link to) and `pages.acceptInvitation` (Nuxt side, where the page mounts) are declared in two places by necessity. A mismatch 404s every invitee — `npx nuxt-backend doctor` cross-checks both (the `invitation-path` finding).

</warning>

## Server-side: workspace-scoped functions

Use the `org` tier from [`createFunctions`](https://nuxt-backend.dev/docs/auth/access/roles) — it verifies membership with a fresh member-table read on every call and injects `ctx.organization`:

```ts [backend/projects.ts]
import { v } from 'convex/values'
import { org } from './functions'

export const create = org.mutation({
  args: { name: v.string() },
  handler: (ctx, { name }) =>
    ctx.db.insert('projects', { name, organizationId: ctx.organization.id }),
})
```

<page-keys>

- Try it live in the playground: [/playground/platform/workspaces](https://nuxt-backend.dev/playground/platform/workspaces)
- API reference: [/docs/reference/api/use-organization](https://nuxt-backend.dev/docs/reference/api/use-organization)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-organization.ts](https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-organization.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
