Are you using nextjs, shadcn and need a more robust table to display your information?
Combine Shadcn's UI components with TanStack Table in Next.js to create dynamic and interactive data tables. This integration leverages Shadcn's minimal design and TanStack's powerful features like sorting, filtering, and pagination.
TanStack Table Docs: TanStack TableExternal Link
shadcn Docs: shadcnExternal Link
Here is an example of the table we are going to build:
If you want to see the full code: Shadcn Table ExampleExternal Link
1. Installation
Shadcn has a great installation guide in case you want to check it out: Installation GuideExternal Link
If you have not installed shadcn you will need to begin with npx shadcn-ui@latest init
, Then we will need to install some other components from shadcn:
1. Shadcn components
npx shadcn-ui@latest add table badge button command popover separator dropdown-menu input select
bash
2. TanStack Table
npm install @tanstack/react-table
bash
3. Optional installs
Icons to make the information more attractive Radix iconsExternal Link
npm install @radix-ui/react-icons
bash
And in case you are using zod for schema declaration and validation.
Zod Docs: ZodExternal Link
npm install zod
bash
2. Data and structure
We will need to Create a directory under app/users and add the following files:
app └── users ├── columns.tsx ├── data-table-column-header.tsx ├── data-table-faceted-filter.tsx ├── data-table-pagination.tsx ├── data-table-row-actions.tsx ├── data-table-toolbar.tsx ├── data-table-view-options.tsx ├── data-table.tsx ├── definitions.ts ├── users.ts ├── userSchema.ts └── page.tsx types.d.ts
Folder Structure
- columns.tsx (client component) will contain our column definitions.
- users.ts (local user data).
- data-table-column-header.tsx (client component) will contain the header sorting.
- data-table-faceted-filter.tsx (client component) status sorting and filtering.
- data-table-pagination.tsx (client component) will handle the data pagination
- data-table-row-actions.tsx (client component) table options (editing data, linking to other pages).
- data-table-toolbar.tsx (client component) filtering table data.
- data-table-view-options.tsx (client component) hiding and showing columns.
- data-table.tsx (client component) will contain our
<DataTable />
component. - definitions.ts (status and role data definitions)
- page.tsx (server component) is where we'll fetch data and render our table
We will be using some user information as local data. This is what the data will look like:
export const users: User[] = [ { id: '9953ed85-31a0-4db9-acc8-e25b76176443', userName: 'John Miller', phone: '+1-555-0101', email: 'john.miller@example.com', role: 'client', status: 'inactive', location: '4306 Highland Drive, Seattle, WA 98109', image: 'john.miller.jpg', rtn: 'US2347908701', otherInformation: 'John Miller works in a tech startup in Seattle.', }, // ... ]
app/users/users.ts
You can create a types file at the root of your project:
type User = { id: string; userName: string; phone: string; email: string; location: string; role: 'client' | 'provider'; status: 'active' | 'inactive'; image: string; rtn?: string; otherInformation?: string; createdAt?: date; updatedAt?: date; };
types.d.ts
We can define the columns we need and what data will be displayed, formatted, sorted and filtered.
Let us begin by creating the column definitions:
"use client"; import { ColumnDef } from "@tanstack/react-table"; import clsx from "clsx"; import { DataTableColumnHeader } from "@/app/users/data-table-column-header"; import { DataTableRowActions } from "@/app/users/data-table-row-actions"; import { usersRole, usersStatus } from "@/app/users/definitions"; export const columns: ColumnDef<User>[] = [ { accessorKey: "userName", header: ({ column }) => <DataTableColumnHeader column={column} title={"User"} />, cell: ({ row }) => { return <div className="font-medium">{row.getValue("userName")}</div>; }, }, { accessorKey: "phone", header: ({ column }) => <DataTableColumnHeader column={column} title={"Phone"} />, }, { accessorKey: "email", header: ({ column }) => <DataTableColumnHeader column={column} title={"Email"} />, }, { accessorKey: "location", header: ({ column }) => <DataTableColumnHeader column={column} title={"Location"} />, }, { accessorKey: "role", header: ({ column }) => <DataTableColumnHeader column={column} title={"Role"} />, cell: ({ row }) => { const role = usersRole.find((role) => role.value === row.getValue("role")); if (!role) { // If a value is not what you expect or does not exist you can return null. return null; } return <span>{role.label}</span>; }, }, { accessorKey: "rtn", header: ({ column }) => <DataTableColumnHeader column={column} title={"RTN"} />, }, { accessorKey: "otherInformation", header: ({ column }) => <DataTableColumnHeader column={column} title={"Other Info"} />, }, { accessorKey: "status", header: ({ column }) => <DataTableColumnHeader column={column} title={"Status"} />, cell: ({ row }) => { const status = usersStatus.find((status) => status.value === row.getValue("status")); if (!status) { return null; } return ( <div className={clsx("flex w-[100px] items-center", { "text-red-500": status.value === "inactive", "text-green-500": status.value === "active", })}> {status.icon && <status.icon className="mr-2 h-4 w-4 text-muted-foreground" />} <span>{status.label}</span> </div> ); }, filterFn: (row, id, value) => { return value.includes(row.getValue(id)); }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, }, ];
app/users/columns.tsx
As we can see all the columns have an accessorKey
and it is the same as the fields the User
type defined earlier.
The header
and cell
can be modified to show a specific ui depending on your needs, for our example the status will show green text if the status is active and red text if inactive.
If you are not sure whether some values in your data will be available or not, you can simply return null
just like on line 36.
The other files will look like this:
The definitions contain information of what will be filtered, text and icons for the ui.
import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; export const usersStatus = [ { value: "active", label: "Active", icon: CheckCircledIcon, }, { value: "inactive", label: "Inactive", icon: CrossCircledIcon, }, ]; export const usersRole = [ { value: "client", label: "Client", }, { value: "provider", label: "Provider", }, ];
app/users/definitions.ts
This file contains the headers and also add the functionality of sorting depending on the column.
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons"; import { Column } from "@tanstack/react-table"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> { column: Column<TData, TValue>; title: string; } export function DataTableColumnHeader<TData, TValue>({ column, title, className, }: DataTableColumnHeaderProps<TData, TValue>) { if (!column.getCanSort()) { return <div className={cn(className)}>{title}</div>; } return ( <div className={cn("flex items-center space-x-2", className)}> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent"> <span>{title}</span> {column.getIsSorted() === "desc" ? ( <ArrowDownIcon className="ml-2 h-4 w-4" /> ) : column.getIsSorted() === "asc" ? ( <ArrowUpIcon className="ml-2 h-4 w-4" /> ) : ( <CaretSortIcon className="ml-2 h-4 w-4" /> )} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuItem onClick={() => column.toggleSorting(false)}> <ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Asc"} </DropdownMenuItem> <DropdownMenuItem onClick={() => column.toggleSorting(true)}> <ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Desc"} </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> {"Hide"} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ); }
app/users/data-table-column-header.tsx
We can add actions like viewing, editing and deleting information for a particular row.
"use client"; // import { UserSchema } from "@/app/users/userSchema"; import Link from "next/link"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { Row } from "@tanstack/react-table"; import { Eye, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; interface DataTableRowActionsProps<TData> { row: Row<TData>; } export function DataTableRowActions<TData>({ row }: DataTableRowActionsProps<TData>) { // const user = UserSchema.parse(row.original); // console.log(user.id); // Note: use the id for any action (example: delete, view, edit) return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"> <DotsHorizontalIcon className="h-4 w-4" /> <span className="sr-only">{"Open Menu"}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"} asChild> <Link href={"#"}> <Eye className="w-4 h-4 text-blue-500" /> {<span className="ml-2">{"View"}</span>} </Link> </Button> </DropdownMenuItem> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"} asChild> <Link href={"#"}> <Pencil className="h-4 w-4 text-green-500" /> {<span className="ml-2">{"Update"}</span>} </Link> </Button> </DropdownMenuItem> <DropdownMenuItem> <Button variant={"ghost"} size={"sm"} className={"justify-start w-full"}> <Trash2 className="h-4 w-4 text-red-500" /> {<span className="ml-2">{"Delete"}</span>} </Button> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }
app/users/data-table-row-actions.tsx
In case you do decide to use zod for your data validation, this is how we can create a simple schema.
import { z } from "zod"; export enum Role { "provider" = "provider", "client" = "client", } export enum UserStatus { "active" = "active", "inactive" = "inactive", } export const UserSchema = z.object({ id: z.string(), userName: z.string({}).trim().min(5), phone: z.string({}).trim().min(8), email: z.string({}).email().trim().or(z.literal("")).optional(), location: z.string({}).trim().or(z.literal("")).optional(), role: z.nativeEnum(Role, {}), status: z.nativeEnum(UserStatus, {}), otherInformation: z.string({}).trim().or(z.literal("")).optional(), rtn: z.string({}).trim().or(z.literal("")).optional(), image: z.string().or(z.literal("")).optional(), createdAt: z.date().optional(), updatedAt: z.date().optional(), });
app/users/userSchema.ts
Next, we'll create a <DataTable />
component to help us render our table. This file will contain the <DataTablePagination />
and <DataTableToolbar />
.
"use client"; import React from "react"; import { ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, SortingState, useReactTable, VisibilityState, } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { DataTablePagination } from "./data-table-pagination"; import { DataTableToolbar } from "./data-table-toolbar"; interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; } export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { const [sorting, setSorting] = React.useState<SortingState>([]); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({ location: false, otherInformation: false, }); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const table = useReactTable({ data, columns, state: { sorting, columnVisibility, columnFilters, }, enableRowSelection: true, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnVisibilityChange: setColumnVisibility, getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, }); return ( <div className="space-y-4"> <DataTableToolbar table={table} /> <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 data results"} </TableCell> </TableRow> )} </TableBody> </Table> </div> <DataTablePagination table={table} /> </div> ); }
app/users/data-table.tsx
The data table pagination will have the buttons to move through the pages of data.
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon, } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; interface DataTablePaginationProps<TData> { table: Table<TData>; } export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) { return ( <div className="flex items-center justify-between px-2"> <div className="flex-1 text-sm text-muted-foreground"> {table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} {"row(s) selected"}. </div> <div className="flex items-center space-x-6 lg:space-x-8"> <div className="flex items-center space-x-2"> <p className="text-sm font-medium">{"Rows per page"}</p> <Select value={`${table.getState().pagination.pageSize}`} onValueChange={(value) => { table.setPageSize(Number(value)); }}> <SelectTrigger className="h-8 w-[70px]"> <SelectValue placeholder={table.getState().pagination.pageSize} /> </SelectTrigger> <SelectContent side="top"> {[10, 20, 30, 40, 50].map((pageSize) => ( <SelectItem key={pageSize} value={`${pageSize}`}> {pageSize} </SelectItem> ))} </SelectContent> </Select> </div> <div className="flex w-[100px] items-center justify-center text-sm font-medium"> {"Page"} {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} </div> <div className="flex items-center space-x-2"> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}> <span className="sr-only">{"Go to first page"}</span> <DoubleArrowLeftIcon className="h-4 w-4" /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}> <span className="sr-only">{"Go to previous page"}</span> <ChevronLeftIcon className="h-4 w-4" /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> <span className="sr-only">{"Go to next page"}</span> <ChevronRightIcon className="h-4 w-4" /> </Button> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()}> <span className="sr-only">{"Go to last page"}</span> <DoubleArrowRightIcon className="h-4 w-4" /> </Button> </div> </div> </div> ); }
app/users/data-table-pagination.tsx
The data table toolbar will contain the view options (show/hide columns), sorting and filtering options.
"use client"; import { Cross2Icon } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { usersStatus } from "@/app/users/definitions"; import { DataTableFacetedFilter } from "./data-table-faceted-filter"; import { DataTableViewOptions } from "./data-table-view-options"; interface DataTableToolbarProps<TData> { table: Table<TData>; } export function DataTableToolbar<TData>({ table }: DataTableToolbarProps<TData>) { const isFiltered = table.getState().columnFilters.length > 0; return ( <div className="flex w-full items-center justify-between"> <div className="flex flex-1 items-center space-x-2"> <Input placeholder={"Filter"} value={(table.getColumn("userName")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("userName")?.setFilterValue(event.target.value)} className="h-8 w-[150px] lg:w-[250px]" /> {table.getColumn("status") && ( <DataTableFacetedFilter column={table.getColumn("status")} title={"Status"} options={usersStatus} /> )} {isFiltered && ( <Button variant="outline" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3"> {"Clean Filters"} <Cross2Icon className="ml-2 h-4 w-4" /> </Button> )} </div> <DataTableViewOptions table={table} /> </div> ); }
app/users/data-table-toolbar.tsx
The table can hide and show columns that we choose. In our example the Location and OtherInformation are hidden by default.
"use client"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import { MixerHorizontalIcon } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; interface DataTableViewOptionsProps<TData> { table: Table<TData>; } export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex"> <MixerHorizontalIcon className="mr-2 h-4 w-4" /> {"See Columns"} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[160px]"> <DropdownMenuLabel>{"Edit Columns"}</DropdownMenuLabel> <DropdownMenuSeparator /> {table .getAllColumns() .filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)}> {column.id} </DropdownMenuCheckboxItem> ); })} </DropdownMenuContent> </DropdownMenu> ); }
app/users/data-table-view-options.tsx
We can also filter based on a field, in our example we can filter by status Active
and Inactive
.
import * as React from "react"; import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; import { Column } from "@tanstack/react-table"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; interface DataTableFacetedFilterProps<TData, TValue> { column?: Column<TData, TValue>; title?: string; options: { label: string; value: string; icon?: React.ComponentType<{ className?: string }>; }[]; } export function DataTableFacetedFilter<TData, TValue>({ column, title, options, }: DataTableFacetedFilterProps<TData, TValue>) { const facets = column?.getFacetedUniqueValues(); const selectedValues = new Set(column?.getFilterValue() as string[]); return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-8 border-dashed"> <PlusCircledIcon className="mr-2 h-4 w-4" /> {title} {selectedValues?.size > 0 && ( <> <Separator orientation="vertical" className="mx-2 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden"> {selectedValues.size} </Badge> <div className="hidden space-x-1 lg:flex"> {selectedValues.size > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal"> {selectedValues.size} {"Selected"} </Badge> ) : ( options .filter((option) => selectedValues.has(option.value)) .map((option) => ( <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal"> {option.label} </Badge> )) )} </div> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0" align="start"> <Command> <CommandInput placeholder={title} /> <CommandList> <CommandEmpty>{"No Filter results"}</CommandEmpty> <CommandGroup> {options.map((option) => { const isSelected = selectedValues.has(option.value); return ( <CommandItem key={option.value} onSelect={() => { if (isSelected) { selectedValues.delete(option.value); } else { selectedValues.add(option.value); } const filterValues = Array.from(selectedValues); column?.setFilterValue(filterValues.length ? filterValues : undefined); }}> <div className={cn( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible" )}> <CheckIcon className={cn("h-4 w-4")} /> </div> {option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />} <span>{option.label}</span> {facets?.get(option.value) && ( <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> {facets.get(option.value)} </span> )} </CommandItem> ); })} </CommandGroup> {selectedValues.size > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem onSelect={() => column?.setFilterValue(undefined)} className="justify-center text-center"> {"Clean Filters"} </CommandItem> </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> ); }
app/users/data-table-faceted-filter.tsx
Finally, we'll render our table in our page component.
import { columns } from "@/app/users/columns"; import DataTable from "@/app/users/data-table"; import { users } from "@/app/users/users"; export default async function Home() { // This is where you would fetch external data: // const exampleExternalData = await fetchExternalDataFunction(); // In our example we use local data return ( <div className="container p-2"> <DataTable data={users} columns={columns} /> </div> ); }
app/page.tsx