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
✔ 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:
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);
};
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:
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:
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,
);
},
};
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:
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:
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:
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:
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:
<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
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:
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.
/invite/<id> is a link you can put in your own email, a chat message or anywhere else.Going further
- Full CRUD scoped to a team with
team:current_team - Undo with
vela disable teams - Pattern page: Team management · Docs: vela enable teams


