In this post I'll show you how to make a table in a easy way with Nextjs and shadcn/ui.
So, I'll use the Data Table component.
Setup
To start the project use the following line. You can also find this and a lot of things I will use in this tutorial acessing the shadcn/ui docs.
pnpm create next-app@latest table-with-nextjs --typescript --tailwind --eslint
You can choose the default answers to the questions that will appear and run the init command:
pnpm dlx shadcn-ui@latest init
Again you can choose the default:
Would you like to use TypeScript (recommended)? no / yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › app/globals.css
Do you want to use CSS variables for colors? › no / yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › no / yes
The project setup is done!
Data Table component
pnpm dlx shadcn-ui@latest add table
pnpm add @tanstack/react-table
Create this folder and files on components folder:
components
└── poke-table
├── columns.tsx
└── data-table.tsx
Before the columns setup, let's install the Avatar component:
pnpm dlx shadcn-ui@latest add avatar
Next step, the data for populate the table. Create the data folder with the pokemons.ts and copy and paste the data from this repository.
data
└── pokemons.ts
After, create the types folder with index.ts file.
export interface PokemonName {
english: string;
japanese: string;
chinese: string;
french: string;
}
export interface PokemonBaseStats {
HP: number;
Attack: number;
Defense: number;
"Sp. Attack": number;
"Sp. Defense": number;
Speed: number;
}
export interface PokemonProfile {
height: string;
weight: string;
egg?: string[];
ability: string[] | string[][];
gender: string;
}
interface PokemonEvolution {
prev?: string[];
next?: string[] | string[][];
}
export interface PokemonImage {
sprite: string;
thumbnail: string;
}
export interface Pokemon {
id: number;
name: PokemonName;
type: string[];
base?: PokemonBaseStats;
species: string;
description: string;
evolution: PokemonEvolution;
profile: PokemonProfile;
image: PokemonImage;
}
Now let's install the DropdownMenu component and activate the sorting
pnpm dlx shadcn-ui@latest add dropdown-menu
import {
ArrowDownIcon,
ArrowUpIcon,
ArrowUpDownIcon,
EyeOffIcon,
} from "lucide-react"
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" />
) : (
<ArrowUpDownIcon 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)}>
<EyeOffIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
Install the Toast component and add the Row Actions
pnpm dlx shadcn-ui@latest add toast
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Pokétable',
description: "Catch 'em all",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
<Toaster />
</body>
</html>
)
}
"use client"
import { FileEditIcon, MoreHorizontalIcon, Trash2Icon } from "lucide-react";
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function DataTableRowActions({ row }: { row: number }) {
const { toast } = useToast();
const updatePokemon = (row: number) => {
// Add your api call
toast({
title: "Sucess",
description: `Updated Pokémon: id ${row}`,
})
}
const deletePokemon = (row: number) => {
// Add your api call
toast({
title: "Sucess",
description: `Deleted Pokémon: id ${row}`,
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => updatePokemon(row)}
>
<FileEditIcon width={14} height={14} />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-500 focus:text-red-500"
onClick={() => deletePokemon(row)}
>
<Trash2Icon width={14} height={14} />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Now we can define the columns
"use client"
import { ColumnDef } from "@tanstack/react-table";
import { Pokemon } from "@/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export const columns: ColumnDef<Pokemon>[] = [
{
accessorKey: "name.english",
header: "Name",
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar className="w-7 h-7 rounded-none">
<AvatarImage
src={row?.original?.image.thumbnail}
alt={row?.original?.name.english}
title={row?.original?.name.english}
className="w-7 h-7"
/>
<AvatarFallback>P</AvatarFallback>
</Avatar>
<p>{row?.original?.name.english}</p>
</div>
)
}
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => {
const { type } = row.original;
return (
<div className="flex gap-1">
{type.map((typeName) => (
<Avatar key={typeName} className="w-5 h-5">
<AvatarImage
src={`https://raw.githubusercontent.com/raphaelandrews/table-crud-nextjs/f47d73995f8fcc6a44f2848caeace9725454937b/public/images/pokemons-types-icons/${typeName.toLowerCase()}.svg`}
alt={typeName}
title={typeName}
/>
<AvatarFallback>T</AvatarFallback>
</Avatar>
))}
</div>
);
},
},
{
accessorKey: "profile.height",
header: "Height",
},
{
accessorKey: "profile.weight",
header: "Weight",
},
{
accessorKey: "id",
header: "ID",
},
]
Install the Select and Button components.
pnpm dlx shadcn-ui@latest add button
pnpm dlx shadcn-ui@latest add select
Add the pagination
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
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>
<ChevronsLeftIcon 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>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
Create the DataTable component
"use client"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { DataTablePagination } from "@/components/poke-table/data-table-pagination"
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(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<div className="space-y-4">
<div className="mt-8 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>
<DataTablePagination table={table} />
</div>
)
}
Let's clean the page.tsx inside the app folder and import the DataTable component
import { columns } from "@/components/poke-table/columns";
import { DataTable } from "@/components/poke-table/data-table";
import { pokemons } from "@/data/pokemons";
export default function Home() {
return (
<main className="container min-h-screen mx-auto py-10">
<h1
className="
text-2xl
md:text-3xl
font-bold
text-center
mt-8
leading-tight
lg:leading-[1.1]
tracking-tighter
"
>
Pokétable
</h1>
<DataTable columns={columns} data={pokemons} />
</main>
)
}
Done!