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

terminal
npx shadcn add dropdown-menu sidebar

Native: 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
organization-switcher-menu.tsx
"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 example
organization-switcher.tsx
import { 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>
  );
}