# How to add teams and invites to a SvelteKit app

Workspaces with members, roles and invites by email or link, and a team switcher in the sidebar. vela enable teams creates the collections, the pages and 36 tests, and gives every generator a current_team field for scoping data by team.

Pattern: [Enable teams](https://velastack.dev/patterns/enable-teams) · Docs: https://docs.velastack.dev/enable/teams

Sooner or later a customer asks to add a coworker. From then on the app has workspaces, and every record belongs to one: who can see it, who can invite, who can remove whom, and how a user moves between the teams they are in. `vela enable teams` adds that layer to an app with [auth](https://velastack.dev/tutorials/sveltekit-login-and-signup): the collections, the pages to manage a team, invites by email and by link, an owner/admin/member model, and a switcher in the sidebar.

## Prerequisites

A vela project with a backend and authentication enabled. If you have not done that yet, [add login and signup](https://velastack.dev/tutorials/sveltekit-login-and-signup) first.

## Run the command

```sh
$ vela enable teams
✔ Created 39 files, modified 4
✔ Added collections teams, team_memberships, team_invites, team_invite_links
  src/routes/(app)/teams/
  src/routes/(app)/teams/new/
  src/routes/(app)/teams/[id]/
  src/routes/(app)/teams/[id]/invite/
  src/routes/(app)/teams/[id]/member/[member_id]/
  src/routes/(app)/teams/switch/[id]/
  src/routes/(app)/invite/[id]/
  src/routes/(app)/join/[id]/
  src/lib/components/team-switcher.svelte
  src/lib/schemas/team.ts
```

A `team_users` view joins memberships to users so a team's members can be queried as one collection.

## How it fits together

A team has an `owner` and a set of `team_memberships`, each with a role of `owner`, `admin` or `member`. The team a user is currently working in is a cookie, set by the switch route and read by the `(app)` layout as `locals.team`:

**src/routes/(app)/teams/switch/[id]/+page.server.ts**

```ts
import { error, redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";

export const load = async ({ params, cookies, url, locals }) => {
  const { id } = params;
  const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";

  try {
    await locals.pb.collection("teams").getOne(id);
  } catch {
    return error(404, { message: "Team not found" });
  }

  cookies.set("team", id, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  return redirect(303, redirectUrl);
};
```

**src/routes/(app)/+layout.server.ts**

```ts
export const load = async ({ locals, depends }) => {
  const user = locals.pb.authStore.record!;
  // [!code highlight:3]
  const team = locals.team;
  const teams = await locals.pb.collection("teams").getFullList();
  depends("app:team");

  const breadcrumbs = [{ title: "Home", url: "/dashboard" }];

  return { user, team, teams, breadcrumbs };
};
```

`depends("app:team")` is what lets the switcher refresh every page's data after a switch without a full reload.

## What was generated

### Create a team

Creating a team also creates the owner's membership and a shareable invite link:

**src/routes/(app)/teams/new/+page.server.ts**

```ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";
import { redirect } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";

export const load = async () => {
  const form = await superValidate(zod4(teamSchema));
  return { form };
};

export const actions = {
  default: async ({ locals, request, cookies }) => {
    const user = locals.pb.authStore.record!;
    const form = await superValidate(request, zod4(teamSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let team;

    try {
      team = await locals.pb.collection("teams").create({
        name: form.data.name,
        owner: user.id,
      });
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    await locals.admin.collection("team_memberships").create({
      user: user.id,
      team: team.id,
      role: "owner",
    });

    await locals.admin.collection("team_invite_links").create({
      team: team.id,
      name: team.name,
    });

    redirect("/teams", { type: "toast", message: "Team created" }, cookies);
  },
};
```

### Invite by email

An invite is a record with the invitee's email. Next time that person signs in, it shows on their Teams page as pending, next to the teams they own and belong to:

**src/routes/(app)/teams/[id]/invite/+page.server.ts**

```ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamInviteSchema } from "$lib/schemas/teamInvite";
import { redirect } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";

export const load = async () => {
  const form = await superValidate(zod4(teamInviteSchema));
  return { form };
};

export const actions = {
  default: async ({ locals, params, request, cookies }) => {
    const form = await superValidate(request, zod4(teamInviteSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    const team = await locals.pb.collection("teams").getOne(params.id);

    try {
      await locals.pb.collection("team_invites").create({
        email: form.data.email,
        team: params.id,
        name: team.name,
      });
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    redirect(
      `/teams/${params.id}`,
      { type: "toast", message: "Invite sent" },
      cookies,
    );
  },
};
```

**src/routes/(app)/teams/+layout.server.ts**

```ts
export const load = async ({ locals, parent }) => {
  const { user, breadcrumbs } = await parent();

  const ownedTeams = await locals.pb.collection("teams").getFullList({
    filter: locals.pb.filter("owner = {:owner}", { owner: user.id }),
    expand: "team_users_via_team",
  });

  const memberships = await locals.pb
    .collection("team_memberships")
    .getFullList({
      filter: locals.pb.filter("user = {:user}", { user: user.id }),
      expand: "team",
    });

  const pendingInvites = await locals.pb
    .collection("team_invites")
    .getFullList({
      filter: locals.pb.filter("email = {:email}", { email: user.email }),
      expand: "team",
    });

  const teams = [
    ...ownedTeams.map((team) => ({
      type: "team" as const,
      id: team.id,
      name: team.name,
      role: "owner" as const,
      members: team.expand?.team_users_via_team?.length || 0,
    })),
    ...memberships
      .filter((membership) => membership.expand?.team.owner !== membership.user)
      .map((membership) => ({
        type: "membership" as const,
        id: membership.team,
        name: membership.expand?.team.name,
        role: membership.role,
      })),
    ...pendingInvites.map((invite) => ({
      type: "invite" as const,
      id: invite.team,
      name: invite.expand?.team.name,
      role: null,
      invite_id: invite.id,
    })),
  ];

  return {
    teams,
    breadcrumbs: [...breadcrumbs, { title: "Teams", url: "/teams" }],
  };
};
```

Accepting creates the membership and deletes the invite. The same happens at `/invite/<id>`, a link you can send any way you like:

**src/routes/(app)/teams/+page.server.ts**

```ts
import { error, fail } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";

export const actions = {
  acceptInvite: async ({ locals, request, cookies }) => {
    const user = locals.pb.authStore.record!;
    const form = await request.formData();
    const inviteId = form.get("invite_id") as string;
    if (!inviteId) {
      return fail(400, { error: "Invite ID is required" });
    }

    let invite;
    try {
      invite = await locals.pb.collection("team_invites").getOne(inviteId);
    } catch {
      return error(404, { message: "Invite not found" });
    }

    await locals.admin.collection("team_memberships").create({
      user: user.id,
      team: invite.team,
      role: "member",
    });

    await locals.admin.collection("team_invites").delete(invite.id);

    redirect(303, `/teams`, { type: "toast", message: "Joined team" }, cookies);
  },
  leaveTeam: async ({ locals, request, cookies }) => {
    const user = locals.pb.authStore.record!;
    const form = await request.formData();
    const teamId = form.get("team_id") as string;
    if (!teamId) {
      return fail(400, { error: "Team ID is required" });
    }

    let teamMembership;
    try {
      teamMembership = await locals.pb
        .collection("team_memberships")
        .getFirstListItem(
          locals.pb.filter("team = {:team} && user = {:user}", {
            team: teamId,
            user: user.id,
          }),
        );
    } catch {
      return error(404, { message: "Team not found" });
    }

    if (teamMembership.role === "owner") {
      return error(400, { message: "You cannot leave a team you own" });
    }

    await locals.pb.collection("team_memberships").delete(teamMembership.id);

    redirect(303, `/teams`, { type: "toast", message: "Left team" }, cookies);
  },
};
```

### Invite by link

Every team has a `/join/<id>` link. Anyone signed in who opens it becomes a member:

**src/routes/(app)/join/[id]/+page.server.ts**

```ts
import { error } from "@sveltejs/kit";
import { redirect } from "sveltekit-flash-message/server";

export const load = async ({ params, locals }) => {
  let invite;
  try {
    invite = await locals.pb.collection("team_invite_links").getOne(params.id, {
      expand: "team",
    });
  } catch {
    return error(404, { message: "Invite link not found" });
  }

  return { invite };
};

export const actions = {
  default: async ({ locals, params, cookies }) => {
    const user = locals.pb.authStore.record!;

    let invite;
    try {
      invite = await locals.pb
        .collection("team_invite_links")
        .getOne(params.id, {
          expand: "team",
        });
    } catch {
      return error(404, { message: "Invite link not found" });
    }

    await locals.admin.collection("team_memberships").create({
      user: user.id,
      team: invite.team,
      role: "member",
    });

    redirect(
      303,
      `/dashboard`,
      { type: "toast", message: "Joined team" },
      cookies,
    );
  },
};
```

### Roles

The team page is for owners and admins; anyone else is sent back to `/teams`:

**src/routes/(app)/teams/[id]/+layout.server.ts**

```ts
import { redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { teamSchema } from "$lib/schemas/team";

export const load = async ({ params, locals, parent }) => {
  const { user, breadcrumbs } = await parent();
  const { id } = params;

  let team;
  try {
    team = await locals.pb.collection("teams").getOne(id, {
      expand: "team_invite_links_via_team",
    });
  } catch {
    redirect(303, "/teams");
  }

  let userMembership;
  try {
    userMembership = await locals.pb.collection("team_users").getFirstListItem(
      locals.pb.filter(
        'team = {:team} && id = {:user} && (role = "admin" || role = "owner")',
        {
          team: id,
          user: user.id,
        },
      ),
    );
  } catch (error) {
    redirect(303, "/teams");
  }

  const role = userMembership.role;

  // Get all team members with their roles
  const teamMembers = await locals.pb.collection("team_users").getFullList({
    filter: locals.pb.filter("team = {:team}", { team: id }),
  });

  // Get pending invites for this team
  const pendingInvites = await locals.pb
    .collection("team_invites")
    .getFullList({
      filter: locals.pb.filter("team = {:team}", { team: id }),
    });

  const form = await superValidate(team, zod4(teamSchema));

  return {
    team,
    role,
    teamMembers,
    pendingInvites,
    inviteLink: team.expand?.team_invite_links_via_team,
    form,
    breadcrumbs: [
      ...breadcrumbs,
      { title: "Teams", url: "/teams" },
      { title: team.name, url: `/teams/${id}` },
    ],
  };
};
```

Who may remove or re-role whom is one function, used by the members table and the member page. Owners can do anything, admins can remove members but not other admins, and nobody can change their own role:

**src/routes/(app)/teams/[id]/authorization.ts**

```ts
type Role = "owner" | "admin" | "member";

interface User {
  id: string;
}

interface Member {
  id: string;
  role: Role;
}

export const getAuthorization = ({
  user,
  role,
  member,
}: {
  user: User;
  role: Role;
  member?: Member;
}) => {
  const isSelf = member ? user.id === member.id : false;
  const isOwner = role === "owner";
  const isAdmin = role === "admin";

  if (isSelf) {
    return {
      canRemove: false,
      canChangeRole: false,
    };
  } else if (isOwner) {
    return {
      canRemove: true,
      canChangeRole: true,
    };
  } else if (isAdmin) {
    if (member && member.role === "member") {
      return {
        canRemove: true,
        canChangeRole: false,
      };
    }

    return {
      canRemove: false,
      canChangeRole: false,
    };
  }

  return {
    canRemove: false,
    canChangeRole: false,
  };
};
```

### The switcher

The sidebar lists the user's teams and an "Add team" entry. Picking one visits the switch route with the current path as the redirect, then invalidates `app:team`:

**src/lib/components/team-switcher.svelte**

```svelte
<script lang="ts">
  import type { Snippet } from "svelte";
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
  import * as Sidebar from "$lib/components/ui/sidebar/index.js";
  import { useSidebar } from "$lib/components/ui/sidebar/index.js";
  import PlusIcon from "@lucide/svelte/icons/plus";
  import UsersIcon from "@lucide/svelte/icons/users";
  import { page } from "$app/state";
  import { goto, invalidate } from "$app/navigation";

  let {
    teams,
    children,
  }: { teams: { id: string; name: string }[] } & { children: Snippet } =
    $props();

  const switchTeam = async (teamId: string) => {
    await goto(
      `/teams/switch/${teamId}?redirect=${encodeURIComponent(page.url.pathname)}`,
    );
    invalidate("app:team");
  };

  const sidebar = useSidebar();
</script>

<Sidebar.Menu>
  <Sidebar.MenuItem>
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        {#snippet child({ props })}
          <Sidebar.MenuButton
            {...props}
            size="lg"
            class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
          >
            {@render children?.()}
          </Sidebar.MenuButton>
        {/snippet}
      </DropdownMenu.Trigger>
      <DropdownMenu.Content
        class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
        align="start"
        side={sidebar.isMobile ? "bottom" : "right"}
        sideOffset={4}
      >
        <DropdownMenu.Label class="text-muted-foreground text-xs"
          >Teams</DropdownMenu.Label
        >
        {#each teams as team (team.name)}
          <DropdownMenu.Item
            onSelect={() => switchTeam(team.id)}
            class="gap-2 p-2"
          >
            <div
              class="flex size-6 items-center justify-center rounded-md border"
            >
              <UsersIcon class="size-3.5 shrink-0" />
            </div>
            {team.name}
          </DropdownMenu.Item>
        {/each}
        <DropdownMenu.Separator />
        <DropdownMenu.Item class="gap-2 p-2">
          <div
            class="flex size-6 items-center justify-center rounded-md border bg-transparent"
          >
            <PlusIcon class="size-4" />
          </div>
          <a href="/teams/new" class="text-muted-foreground font-medium"
            >Add team</a
          >
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  </Sidebar.MenuItem>
</Sidebar.Menu>
```

### Tests

Thirty-six server tests cover creating, inviting, accepting, joining, leaving, role changes and every permission branch in the authorization function.

## Try it

```sh
$ vela dev
```

Sign in, open [localhost:5173/teams](http://localhost:5173/teams) and create a team. Open its page, copy the join link, sign in as a second user in a private window and paste it. Back as the owner, promote the new member to admin, then switch teams from the sidebar.

## Scope your data by team

The point of teams is that records belong to one. The generators have a field type for it:

```sh
$ vela generate scaffold projects title:text! team:current_team
```

`current_team` is a relation to `teams` that the generated forms leave out and fill from `locals.team`, and the collection's rules restrict rows to members of that team. The scaffolded list at `/projects` shows the active team's projects, and switching teams changes the list. [The field syntax](https://velastack.dev/tutorials/sveltekit-generator-field-syntax) covers `current_team` and `current_user` together.

> **Note:**
> Email invites are records, not emails: the pattern does not send mail. The invitee sees the invite on their Teams page, and `/invite/&lt;id&gt;` is a link you can put in your own email, a chat message or anywhere else.

## Going further

- [Full CRUD scoped to a team](https://velastack.dev/tutorials/sveltekit-crud-scaffold) with `team:current_team`
- Undo with `vela disable teams`
- [Pattern page: Team management](https://velastack.dev/patterns/enable-teams) · [Docs: vela enable teams](https://docs.velastack.dev/enable/teams)
