Logo VelaStack

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.

3 min read teamsmulti-tenantinvitesrolespocketbasesveltekit

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: 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 first.

Run the command

$ 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
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
export const load = async ({ locals, depends }) => {
  const user = locals.pb.authStore.record!;
  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
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
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
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
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
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
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
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
<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

$ vela dev

Sign in, open 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:

$ 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 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/<id> is a link you can put in your own email, a chat message or anywhere else.

Going further

Related tutorials

How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

3 min read

How to add login and signup to a SvelteKit app

Login, signup, password reset, email verification, one-time codes and OAuth buttons, plus a settings page and a protected dashboard. vela enable auth generates all of it on PocketBase, with a server test for every route.

4 min read

How to add login with SvelteKit remote functions

The same login, signup, reset and settings flows as vela enable auth, with every form posting to a typed remote function instead of a form action. One flag, and each handler shrinks to a single function.

2 min read