I've build a reusable Team component for Next.js 14 using Supabase

I've build a reusable Team component for Next.js 14 using Supabase

Let's build a reusable Team component together

Featured on Hashnode

In this article, I will walk you through all the steps to create a Team component for your application using Next.js, Supabase, and Shadcn. To give you a preview, take a look at the video intro: we'll achieve a component with a table, options such as adding a member, changing status, and deleting the member. All of this in real-time thanks to Supabase's realtime function.

💡
If you enjoyed this article, you can also get my Full-Stack Developer Masterclass for $12 here :)

To start, we are going to create a web application using the following libraries: Next.js, Shadcn & Supabase. Shadcn is an extremely practical and well-designed component library. I highly recommend using it in your projects.

Please chose your folder, open your terminal and type:

npx create-next-app@latest my-app --typescript --tailwind --eslint
npx shadcn-ui@latest init
npm i @supabase/supabase-js

The installation of Shadcn is probably more complex in your case, which is why I invite you to visit this documentation for more information.

Now that our application has been created and we have installed the necessary libraries, we can focus on the project architecture. To do this, I have created a diagram that will show you roughly how I am going to organize the folders and files of my web application.

In this diagram, we can see that our team component is divided into several subcomponents, each of which will have its own logic. Next to it, I have made a diagram of the database table that will contain the teams and their members.

Let's now tackle the design part.

Designing UI and UX

We will now dive into designing our component, and to do this, we have installed a component library that will allow us to move very quickly. We now need to install these components and do a quick review of their design.

Please add these components by typing in your terminal:

npx shadcn-ui@latest add alert-dialog badge button checkbox dialog dropdown-menu input label select sonner table

We can now see that in our component folder, a new subfolder has been created called UI. This is the destination folder for our component library. This is where all the components we imported will end up, and we can use them in our application.

Note: Obviously, each component already has a predetermined design, which will allow us to save a lot of time.

CustomButton.tsx

Before anything else, I would like to create a custom component that will integrate its own loading logic. Here is the code I generated, which is extremely simple to understand, but it uses the button component from our library Shadcn.

// @/components/CustomButton.tsx

'use client';

import { Loader2 } from "lucide-react"; // comes with Shadcn
import { Button } from "./ui/button"; // comes with Shadcn

export default function CustomButton({
  label,
  variant = 'default',
  loading,
  onClick
}: {
  label: string,
  variant?: any,
  loading: boolean,
  onClick: () => any
}) {
  if (loading) return <Button variant="outline" disabled>
    <Loader2 className="animate-spin" />
  </Button>

  return <Button variant={variant} onClick={() => onClick()}>{label}</Button>;
}

This particular component will be necessary to inject the functions that will allow us to add, modify, or delete data regarding our members.

Datatable.tsx

You saw it when we brought in the components from our library. We also need to use a table. And this table will allow us to display all the members of our team. This component is a generic component. That's why I'm placing it at the base of our components folder.

// @/components/Datatable.tsx
"use client"

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table"

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                  </TableHead>
                )
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}

There's nothing particularly extraordinary about this code. It's directly from the Shadcn documentation.

Folder structure

Alright. Now it's time to create a folder structure that will be efficient for us to work with. We'll create two folders: one named "loading," which will contain the loading skeletons, and another named "team," which will encompass the entire architecture of our component (attached is the schema above).

I usually break down my components and subcomponents into well-organized files and folders so that we can immediately find what we're looking for. In this specific case, I've created a "members" folder inside the "teams" folder, and within the "members" folder, there's an "options" folder that will contain child components.

As you can see, at the root of the "team" folder, there are two files: "index.js" and "new.js". The "index.js" will contain the global component, while the "new.js" file will contain the form for adding a new member. I could have placed it in the "members" folder, but it's a personal choice to put my file containing the form for adding a new member at the root of the "team" folder.

Inside my "members" folder is where things get a bit more complex because I break down all my options into small files within a subfolder called "options."

Also, at the root of this "member" folder, there will be "Columns.tsx" file, so here's the code:

// @Components/Team/Members/Columns.tsx

import { Badge } from "@/components/ui/badge";
import { useHelpers } from "@/hooks/useHelpers";
import { supabase } from "@/lib/supabase";
import { ColumnDef } from "@tanstack/react-table";
import { toast } from "sonner";
import Options from "./Options";
import Roles from "./Options/Roles";

export const columns: ColumnDef<any>[] = [
  {
    accessorKey: "name",
    header: "Name",
    cell: ({ row }) => {
      const name: string = row.getValue("name");
      const email: string = row.original.email;
      return <div className="flex items-center gap-2">
        <div className="flex items-center justify-center bg-black text-white font-bold capitalize w-8 h-8 rounded-full">{name[0]}</div>
        <div className="grid">
          <span className="font-medium">{name}</span>
          <span className="text-xs text-neutral-500">{email}</span>
        </div>
      </div>
    },
  }, {
    accessorKey: "role",
    header: "Role",
    cell: ({ row }) => {
      const { open, setOpen, loading, setLoading } = useHelpers();
      const role: string = row.getValue("role");
      const id: string = row.original.id;

      const onRoleChanged = async (v: string) => {
        try {
          setLoading(true);
          const { data, error } = await supabase
            .from('team_members')
            .update({
              role: v
            })
            .eq("id", id)
            .select('*');

          if (data) {
            toast.success("Role updated successfully")
          }
        } catch (error: any) {
          throw new Error(error);
        } finally {
          setOpen(false);
          setLoading(false);
        }
      };

      return <div onClick={() => setOpen(!open)} className="w-[120px]">
        {!open && <span className="text-sm text-neutral-500 capitalize">{role}</span>}
        {open && <Roles {...{ selected: role }} setSelected={(v) => onRoleChanged(v)} />}
      </div>
    }
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status: string = row.getValue("status")
      switch (status) {
        case "pending":
          return <Badge className="hover:bg-transparent capitalize bg-orange-50 text-orange-900">Pending</Badge>
        case "active":
          return <Badge className="hover:bg-transparent capitalize bg-green-50 text-green-900">Active</Badge>
        case "removed":
          return <Badge className="hover:bg-transparent capitalize bg-red-50 text-red-900">Removed</Badge>
        default:
          return <Badge className="capitalize bg-neutral-100 text-neutral-600">Unknown</Badge>
      }
    }
  },
  {
    id: "actions",
    cell: ({ row }) => {
      const user = row.original;
      return <div className="flex justify-end">
        <Options {...{ user }} />
      </div>
    }
  },
]

I linger for a few seconds on this column file. You can see here we describe the model of the columns that be injected in our Datatable.tsx. The specificity here is that I inject code directly into each cell, and the particularity you can see is that for the "roles" column, i have onRoleChanged function that directly call Supabase.

We'll come back later on Supabase but this is directly from this cell that we'll trigger our action to change member's roles.

💡
At this stage, you are missing some components. Don't worry, they will come later.

Team/index.tsx

Alright, at this point, we have our table and our columns, but we need a Team component to display. Here it is:

'use client';

import { useEffect, useState } from "react";
import { DataTable } from "../Datatable";

import { useHelpers } from "@/hooks/useHelpers";
import { supabase } from "@/lib/supabase";
import LoadingTeam from "../Loading/Team";
import { columns } from "./Members/Columns";
import New from "./New";

export default function Team() {
  const [team, setTeam] = useState({
    name: "Team",
    id: 'YOUR_TEAM_ID_TO_TEST'
  });
  const [members, setMembers] = useState<any>([]);
  const { loading, setLoading } = useHelpers();

  const fetchTeam = async () => {
    try {
      setLoading(true);
      const { data, error }: any = await supabase
        .from("teams")
        .select("*, team_members(*)")
        .eq("id", "YOUR_TEAM_ID_TO_TEST")
        .single();

      if (data) {
        const { team_members, ...teamData } = data;
        setTeam(teamData);
        setMembers(team_members);
      }
    } catch (error: any) {
      throw new Error(error);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchTeam();
    const subcription = supabase
      .channel('channel_team_members')
      .on('postgres_changes', {
        event: '*',
        schema: 'public',
        table: 'team_members',
        filter: `team_id=eq.${team.id}`
      }, (payload: any) => {
        fetchTeam();
      })
      .subscribe()
  }, [])

  if (loading) return <LoadingTeam />

  return <div className="grid gap-6 border rounded-lg shadow px-5 py-4 w-full max-w-[800px]">
    <header className="flex items-start justify-between">
      <div className="grid gap-1">
        <h1 className="text-2xl">{team.name || 'Team'}</h1>
        <p>Invite new members in your team.</p>
      </div>
      <New team_id={team.id} />
    </header>
    <main>
      <DataTable columns={columns} data={members} />
    </main>
  </div>;
}

Several steps: as you can see, I have a fetchTeam function that retrieves the teams and stores them in the local state. Then, I trigger this function in useEffect. Finally, I attach a listener (or subscriber) to update my view when there are changes.

Nothing too complicated except for the use of Supabase real-time (which obviously needs to be enabled) and the little trick, the following query to listen to only our team: filter: team_id=eq.${team.id}.

Add a new member

As you can see we're using a New component in the previous component. This is the one we'll integrate to display a popup with a form:

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useHelpers } from "@/hooks/useHelpers"
import { supabase } from "@/lib/supabase"
import { useState } from "react"
import { toast } from "sonner"
import CustomButton from "../CustomButton"
import Roles from "./Members/Options/Roles"

export default function NewMember({ team_id }: { team_id: string }) {
  const { open, setOpen, loading, setLoading } = useHelpers();
  const [member, setMember] = useState({
    name: "",
    email: "",
    role: "member"
  });

  const saveMember = async () => {
    try {
      setLoading(true);
      const { data, error } = await supabase
        .from('team_members')
        .insert({ ...member, team_id })
        .select();

      if (data) {
        toast.success("Team members successfully added.")
      }
    } catch (error: any) {
      throw new Error(error);
    } finally {
      setOpen(false);
      setLoading(false);
    }
  };

  return (
    <Dialog open={open} onOpenChange={() => setOpen(!open)}>
      <DialogTrigger asChild>
        <Button>
          <span>New member</span>
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Add a new member</DialogTitle>
          <DialogDescription>
            Please enter name and email of member. Click save when you&apos;re done.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              Name
            </Label>
            <Input
              id="name"
              placeholder="John Doe"
              defaultValue={member.name}
              className="col-span-3"
              onChange={(e: any) =>
                setMember((prev: any) => ({ ...prev, name: e.target.value }))}
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Email
            </Label>
            <Input
              id="email"
              defaultValue={member.email}
              placeholder="johndoe@gmail.com"
              className="col-span-3"
              onChange={(e: any) =>
                setMember((prev: any) => ({ ...prev, email: e.target.value }))}
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Select role
            </Label>
            <div className="w-[240px]">
              <Roles
                selected={member.role}
                setSelected={(v: string) => {
                  setMember((prev: any) => ({ ...prev, role: v }))
                }}
              />
            </div>
          </div>
        </div>
        <DialogFooter>
          <CustomButton {...{ label: "Send invitation", loading, onClick: saveMember }} />
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Let's focus quickly on this architecture and what we've been trying to achieve here.
First, the goal of this component is adding a new team member, with a prop for the team ID. The component uses several helper functions and states to manage the dialog's open state, loading state, and the new member's details.

  1. State Management:

    • The component uses useState to manage the new member's details (name, email, and role).

    • The useHelpers hook provides functions to manage the dialog's open state and loading state.

  2. saveMember Function:

    • This asynchronous function handles the process of saving the new member's details to the database.

    • It sets the loading state to true while the save operation is in progress.

    • It makes a request to Supabase to insert the new member's details into the team_members table, including the team ID.

    • If the save operation is successful, it displays a success toast message.

    • Regardless of success or failure, it sets the loading state to false and closes the dialog.

  3. Dialog Component:

    • The Dialog component manages the display of the modal dialog for adding a new member.

    • The DialogTrigger component wraps a button that opens the dialog when clicked.

    • The DialogContent component contains the structure and content of the dialog.

      • It includes a header with a title and description.

      • It contains a form with inputs for the new member's name, email, and role.

      • The role selection is managed by a Roles component, which allows the user to choose a role from a dropdown menu.

      • The DialogFooter contains a custom button that triggers the saveMember function to save the new member's details.

  4. Form Handling:

    • The form inputs are controlled components, with their values managed by the member state.

    • When the user types into the name or email input, the corresponding state value is updated.

    • When the user selects a role, the setSelected function updates the role state.

As we can see in this 4 steps, there's nothing too complicated about this component. It consists of a form within a dialog and a trigger to call our Supabase instance. Make sure to thoroughly check your RLS (Row-Level Security) policies before trying or debugging.

Members/Index.tsx

Here is where the code gets a bit more complex because it involves multiple levels of understanding. But here is the basis of our options:

'use client';

import { useEffect, useState } from "react";
import { DataTable } from "../Datatable";

import { useHelpers } from "@/hooks/useHelpers";
import { supabase } from "@/lib/supabase";
// random loading team item, please check the repo to look at it
import LoadingTeam from "../Loading/Team";
import { columns } from "./Members/Columns";
import New from "./New";

export default function Team() {
  const [team, setTeam] = useState({
    name: "Team",
    id: 'YOUR_TEAM_ID_TO_TEST'
  });
  const [members, setMembers] = useState<any>([]);
  const { loading, setLoading } = useHelpers();

  const fetchTeam = async () => {
    try {
      setLoading(true);
      const { data, error }: any = await supabase
        .from("teams")
        .select("*, team_members(*)")
        .eq("id", "YOUR_TEAM_ID_TO_TEST")
        .single();

      if (data) {
        const { team_members, ...teamData } = data;
        setTeam(teamData);
        setMembers(team_members);
      }
    } catch (error: any) {
      throw new Error(error);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchTeam();
    const subcription = supabase
      .channel('channel_team_members')
      .on('postgres_changes', {
        event: '*',
        schema: 'public',
        table: 'team_members',
        filter: `team_id=eq.${team.id}`
      }, (payload: any) => {
        fetchTeam();
      })
      .subscribe()
  }, [])

  if (loading) return <LoadingTeam />

  return <div className="grid gap-6 border rounded-lg shadow px-5 py-4 w-full max-w-[800px]">
    <header className="flex items-start justify-between">
      <div className="grid gap-1">
        <h1 className="text-2xl">{team.name || 'Team'}</h1>
        <p>Invite new members in your team.</p>
      </div>
      <New team_id={team.id} />
    </header>
    <main>
      <DataTable columns={columns} data={members} />
    </main>
  </div>;
}

Here, I fetch team data from a Supabase database, including team members, and update the state accordingly. The component also subscribes to changes in the team members' data, ensuring real-time updates.

If the data is still loading, a loading component is displayed. Once loaded, I render the team information and a data table of team members, allowing for new members to be invited.

Members/Options/index.tsx

It is now time to define the source file for our options, and we will obviously start with index.tsx:

import {
  EllipsisVertical,
  UserX
} from "lucide-react";

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { useHelpers } from "@/hooks/useHelpers";
import Remove from "./Remove";

export default function Options({ user }: any) {
  const { open = false, setOpen, selected, setSelected } = useHelpers();
  const menu: any = [{
      title: "Remove member",
      key: "remove",
      icon: <UserX className="w-[20px]" />
    }
  ]

  return <div>
    {/* Our remove popup */}
    <Remove {...{ user, open: selected === "remove", onClose: () => setSelected(undefined) }} />
    {/* Our dropdown options */}
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <EllipsisVertical className="w-4 cursor-pointer" />
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56" >
        <DropdownMenuGroup >
          {menu.map((item: any, i: number) => <div key={i}>
            <DropdownMenuItem className="flex gap-2 cursor-pointer" onClick={() => setSelected(item.key)}>
              {item.icon}
              <span>{item.title}</span>
            </DropdownMenuItem>
          </div>)}
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  </div>
};

I define a React component that provides dropdown menu options for managing a user. The component uses helper functions to handle the state of the menu and the selected option. It includes a "Remove member" option, which triggers a popup for removing a user.

The dropdown menu displays available actions, and when an action is selected, it updates the state accordingly. The remove popup appears when the "Remove member" option is selected, allowing for user management directly within the dropdown menu.

Why am I doing this popup in a separate file? Because it gives us the option to add as many popups as we want in the future. It's a matter of flexibility.

Members/Options/Remove.tsx

Obviously, our "remove" popup needs to be created. Let's take action and create a Remove.tsx component:

import CustomButton from "@/components/CustomButton";
import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle
} from "@/components/ui/alert-dialog";
import { useHelpers } from "@/hooks/useHelpers";
import { supabase } from "@/lib/supabase";
import { toast } from "sonner";

export default function Remove({ user, open, onClose }: any) {
  const { loading, setLoading } = useHelpers();

  const removeMember = async () => {
    try {
      setLoading(true);
      const { data, error } = await supabase
        .from('team_members')
        .update({
          status: "removed"
        })
        .eq("id", user.id)
        .select('*');

      if (data) {
        toast.success("User successfully removed from team.")
      }
    } catch (error: any) {
      throw new Error(error);
    } finally {
      setLoading(false);
    }
  }

  return (
    <AlertDialog open={open}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            {user.name || 'Member'} will no longer be part of the team and will no longer have access to team-related content.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel onClick={() => onClose()} className="bg-red-500 text-white">
            Cancel
          </AlertDialogCancel>
          <CustomButton {...{ label: 'Confirm', loading, onClick: removeMember }} />
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

As you can see up here, we externalize our AlertDialog into this component that carries all the logic itself. The component uses helper functions to manage loading state. When the "Confirm" button is clicked, it updates the user's status to "removed" in the Supabase database. If successful, it shows a success toast message.

It renders an alert dialog asking for confirmation before removing the user, with "Cancel" and "Confirm" buttons to handle the user's decision.

Members/Options/Roles.tsx

And the last component, the one we've all been waiting for since we started editing our columns:


import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

export default function Roles({
  selected = 'member',
  setSelected
}: {
  selected?: string;
  setSelected?: (value: string) => void;
}) {
  const roles = [
    "admin",
    "manager",
    "member"
  ];
  return (
    <Select defaultValue={selected} onValueChange={setSelected} >
      <SelectTrigger className="w-full capitalize">
        <SelectValue placeholder="Select a role" />
      </SelectTrigger>
      <SelectContent >
        <SelectGroup>
          <SelectLabel>Roles</SelectLabel>
          {roles.map(role => <SelectItem className="capitalize" key={role} value={role}>{role}</SelectItem>)}
        </SelectGroup>
      </SelectContent>
    </Select>
  )
}

For selecting a role, I decided to create this component previously used in Columns.tsx with props for the selected role and a function to set the selected role. The component uses a list of roles ("admin", "manager", "member") to populate the options in the select dropdown (there's probably better ways to do it but here it's faster).

It renders a select dropdown with the current selected role as the default value and updates the selected role when a new one is chosen. The dropdown options are displayed in a grouped and labeled format, allowing the user to choose their role easily.

Conclusion

There you have it for the design part. If you enjoyed it or have any comments, feel free to post a comment or to watch the video. See you soon!

Guillaume Duhan