All recipes
Recipe
Organization switcher
A menu of the user's organizations that switches the active one and refreshes the session for its tenant claim. Web and native call useOrganizations from better-supabase/blocks/organizations/react.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add dropdown-menu sidebarNative: React Native
The snippet uses ActivityIndicator, Pressable, Text, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.
3.Install the glue
Install the block with npx better-supabase sql add organizations. The glue items are copied into your app, so edit them as you need.
Web
None: the hook calls the block from the client.
Native
None: the hook calls the block from the client.
4.Write the component
Web: React and shadcn
View in the example"use client";
import { DbException } from "better-supabase";
import { useOrganizations } from "better-supabase/blocks/organizations/react";
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";
import type { OrganizationSummary } from "../organization-queries";
import { loadMyOrganizations } from "../organization-actions";
import { useRefreshSession } from "../use-refresh-session";
import { useRoleLabel } from "../use-role-label";
import { CreateOrganizationDialog } from "./create-organization-dialog";
function Mark({ name }: { name: string }) {
return (
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg text-sm font-semibold">
{name.slice(0, 1).toUpperCase()}
</div>
);
}
async function loadOrganizations(): Promise<readonly OrganizationSummary[]> {
const result = await loadMyOrganizations({});
if (!result.ok) throw new DbException(result.error);
return result.data;
}
/**
* The organization menu. The server renders it with its rows; switching
* goes through `switch_organization`, refreshes the session for the new
* tenant claim, then re-renders the server tree.
*/
export function OrganizationSwitcherMenu({
initial,
activeId: serverActiveId,
}: {
initial: readonly OrganizationSummary[];
activeId: string | null;
}) {
const t = useExtracted("organization");
const roleLabel = useRoleLabel();
const errorMessage = useErrorMessage();
const refreshSession = useRefreshSession();
const [pending, startTransition] = useTransition();
const [creating, setCreating] = useState(false);
const { organizations, active, activeId, switchTo } = useOrganizations({
load: loadOrganizations,
initial,
activeId: serverActiveId,
schema: blockConfig.organizations.api,
onSwitched: () => refreshSession("/"),
});
const choose = (organization: OrganizationSummary) => {
startTransition(async () => {
const result = await switchTo(organization.id);
if (!result.ok) {
toast.error(errorMessage(result.error));
return;
}
toast.success(t("Switched to {name}", { name: organization.name }));
});
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
disabled={pending}
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent"
aria-label={t("Switch organization")}
/>
}
>
<Mark name={active?.name ?? "?"} />
<div className="grid flex-1 text-left text-sm leading-tight">
<span
className="truncate font-medium"
data-testid="organization-name"
>
{active?.name ?? t("No organization")}
</span>
<span className="text-muted-foreground truncate text-xs capitalize">
{active ? active.plan : t("Create one to start")}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start" side="bottom">
<DropdownMenuGroup>
<DropdownMenuLabel>{t("Organizations")}</DropdownMenuLabel>
{(organizations ?? initial).map((organization) => (
<DropdownMenuItem
key={organization.id}
className="gap-2"
onClick={() => {
if (organization.id !== activeId) choose(organization);
}}
>
<Mark name={organization.name} />
<div className="grid flex-1 leading-tight">
<span className="truncate">{organization.name}</span>
<span className="text-muted-foreground text-xs">
{roleLabel(organization.role)}
</span>
</div>
{organization.id === activeId ? <CheckIcon /> : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCreating(true);
}}
>
<PlusIcon />
{t("Create organization")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateOrganizationDialog open={creating} onOpenChange={setCreating} />
</>
);
}
Native: React Native
View in the exampleimport { useOrganizations } from "better-supabase/blocks/organizations/react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { API_SCHEMA } from "../lib/blocks";
/**
* The caller's organizations. Pressing one switches to it, refreshes the
* session so the token carries the new tenant, and loads the list again.
*/
export function OrganizationSwitcher() {
const { organizations, activeId, switching, error, switchTo } =
useOrganizations({ schema: API_SCHEMA });
if (organizations === undefined) return <ActivityIndicator />;
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 17, fontWeight: "600" }}>Organizations</Text>
{organizations.map((organization) => (
<Pressable
key={organization.id}
accessibilityRole="button"
accessibilityState={{
selected: organization.id === activeId,
busy: organization.id === switching,
}}
disabled={switching !== undefined || organization.id === activeId}
onPress={() => void switchTo(organization.id)}
style={{ flexDirection: "row", gap: 8, paddingVertical: 8 }}
>
<Text style={{ width: 16 }}>
{organization.id === activeId ? "✓" : ""}
</Text>
<Text style={{ flex: 1 }}>{organization.name}</Text>
<Text style={{ color: "#737373" }}>{organization.role}</Text>
</Pressable>
))}
{error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
</View>
);
}