A feature-rich, server-side data table implementation built with React, TanStack Table, Laravel, and Inertia.js. This data table provides pagination, sorting, search, bulk actions, and column visibility controls out of the box.
Main datatable view with pagination, search, sorting, column visibility, bulk actions
- React 18 - Modern UI library with hooks and concurrent features
- TypeScript - Type-safe JavaScript with excellent developer experience
- Inertia.js - Modern monolith approach connecting Laravel and React seamlessly
- TanStack Table - Powerful headless table library for complex data interactions
- Tailwind CSS - Utility-first CSS framework for rapid UI development
- Radix UI - Unstyled, accessible UI primitives for custom design systems
- Lucide Icons - Beautiful & consistent icon library
- Vite - Fast build tool and development server
- Laravel 11 - Elegant PHP framework with rich ecosystem
- PHP 8.3+ - Modern PHP with performance improvements and type safety
- MySQL/PostgreSQL - Robust database with full-text search capabilities
- Laravel Resources - API resource transformation for consistent data formatting
- Laravel Pagination - Built-in pagination with query string persistence
Make sure you have the following installed on your system:
- PHP 8.2+ with extensions:
mbstring,xml,ctype,json,bcmath,fileinfo,tokenizer - Composer - PHP dependency manager
- Node.js 18+ and npm (or yarn/pnpm)
- MySQL 8.0+ or PostgreSQL 13+
- Git
Clone the repository
git clone https://github.com/your-username/data-table.git cd data-tableInstall PHP dependencies
composer install
Install Node.js dependencies
npm install # or yarn install # or pnpm install
Environment setup
# Copy environment file cp .env.example .env # Generate application key php artisan key:generate
Configure your
.envfileAPP_NAME="Data Table"APP_URL=http://localhost:8000DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=data_tableDB_USERNAME=your_usernameDB_PASSWORD=your_password
Database setup
# Create database (make sure MySQL/PostgreSQL is running)# Then run migrations php artisan migrate # Seed with sample data (optional) php artisan db:seed
Build frontend assets
# For development npm run dev # For production npm run build
Start the development server
# In one terminal - Laravel server php artisan serve # In another terminal - Vite dev server (for hot reload) npm run dev
Access the application
Open your browser and visit:
http://localhost:8000
# Watch for file changes (auto-reload)
npm run dev
# Run Laravel with specific host/port
php artisan serve --host=0.0.0.0 --port=8080
# Clear application cache
php artisan cache:clear
php artisan config:clear
php artisan view:clear
# Run database migrations
php artisan migrate:fresh --seed
# Generate TypeScript types for Laravel routes (if using Ziggy)
php artisan ziggy:generateIf you prefer using Docker:
# Using Laravel Sail
./vendor/bin/sail up -d
# Install dependencies inside container
./vendor/bin/sail composer install
./vendor/bin/sail npm install
# Run migrations
./vendor/bin/sail artisan migrate --seed
# Build assets
./vendor/bin/sail npm run devCommon Issues:
- Vite connection refused: Make sure both
php artisan serveandnpm run devare running - Database connection error: Verify database credentials in
.env - Permission errors: Set proper permissions:
chmod -R 775 storage bootstrap/cache
- Missing APP_KEY: Run
php artisan key:generate
- 🔍 Server-side Search - Debounced search with query parameter persistence
- 📄 Server-side Pagination - Configurable page sizes with navigation controls
- 🔄 Server-side Sorting - Click-to-sort columns with visual indicators
- ✅ Bulk Actions - Select multiple rows and perform batch operations
- 🗑️ Bulk Delete - Built-in bulk delete functionality with confirmation dialog
- 👁️ Column Visibility - Show/hide columns with localStorage persistence
- 📱 Responsive Design - Works on desktop and mobile devices
- 🎯 TypeScript Support - Fully typed with generic interfaces
- 🎨 Customizable - Extensible styling and behavior
- Demo
- Tech Stack
- Getting Started
- Features
- Components Overview
- Quick Start
- Frontend Usage
- Backend Implementation
- API Reference
- Examples
- Customization
- Advanced Usage
- License
| Component | File | Description |
|---|---|---|
DataTable | resources/js/components/datatable.tsx | Main table component with all features |
DataTableToolbar | resources/js/components/datatable-toolbar.tsx | Search, bulk actions, column visibility |
DataTablePagination | resources/js/components/datatable-pagination.tsx | Pagination controls and page size selector |
DataTableColumnHeader | resources/js/components/datatable-column-header.tsx | Sortable column headers with sort indicators |
| File | Description |
|---|---|
resources/js/hooks/use-column-visibility.tsx | Hook for managing column visibility with localStorage |
resources/js/types/index.d.ts | TypeScript interfaces and types |
import{DataTable}from'@/components/datatable';import{ColumnDef}from'@tanstack/react-table';// Define your data typeinterfaceUser{id: number;name: string;email: string;created_at: string;}// Define columnsconstcolumns: (ColumnDef<User>&{enable_sorting?: boolean})[]=[{accessorKey: 'id',header: 'ID',enable_sorting: true,},{accessorKey: 'name',header: 'Name',enable_sorting: true,},{accessorKey: 'email',header: 'Email',enable_sorting: true,},];// Use in your page componentfunctionUsersPage({ usersData }: {usersData: PaginatedData<User>}){return<DataTablecolumns={columns}data={usersData.data}paginatedData={usersData}tableKey="users-table"/>;}<DataTablecolumns={columns}data={usersData.data}paginatedData={usersData}activeBulkActions={true}bulkDelete={{route: route('users.bulk-delete'),title: 'Delete Users',description: 'Are you sure you want to delete the selected users?',}}tableKey="users-table"/>Columns follow TanStack Table's ColumnDef interface with an additional enable_sorting property:
constcolumns: (ColumnDef<YourDataType>&{enable_sorting?: boolean})[]=[{accessorKey: 'field_name',header: 'Display Name',enable_sorting: true,// Enable server-side sorting for this columncell: ({ row })=>{// Custom cell renderingreturn<div>{row.original.field_name}</div>;},},{header: 'Actions',accessorKey: 'actions',enable_sorting: false,cell: ({ row })=>{return(<divclassName="flex gap-2"><ButtononClick={()=>editItem(row.original.id)}>Edit</Button><ButtononClick={()=>deleteItem(row.original.id)}>Delete</Button></div>);},},];constbulkActions: BulkAction<User>[]=[{label: 'Export Selected',icon: Download,onClick: (selectedRows)=>{// Handle exportexportUsers(selectedRows);},},{label: 'Archive Selected',icon: Archive,className: 'text-orange-600',onClick: (selectedRows)=>{// Handle archivearchiveUsers(selectedRows);},},];<DataTable// ... other propsbulkActions={bulkActions}activeBulkActions={true}/>;interfaceDataTableProps<TData,TValue>{columns: (ColumnDef<TData,TValue>&{enable_sorting?: boolean})[];data: TData[];paginatedData?: PaginatedData<TData>;bulkActions?: BulkAction<TData>[];bulkDelete?: {route: string;title?: string;description?: string;};activeBulkActions?: boolean;tableKey?: string;// For localStorage column visibility}<?phpnamespaceApp\Http\Controllers;
useApp\Http\Resources\UserResource;
useApp\Models\User;
useIlluminate\Http\Request;
useInertia\Inertia;
class UserController extends Controller
{
publicfunctionindex(Request$request)
{
// Extract query parameters with defaults$queryParams = request()->only(['search', 'page', 'per_page', 'sort_by', 'sort_dir']) + [
'sort_by' => 'id',
'sort_dir' => 'desc',
'per_page' => 10,
'page' => 1
];
$users = User::query()
// Search functionality
->when($request->search, function ($query, $search) {
$query->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%');
})
// Sorting
->orderBy($queryParams['sort_by'], $queryParams['sort_dir'])
// Pagination
->paginate($queryParams['per_page'])
->withQueryString();
return Inertia::render('users/index', [
'usersData' => UserResource::collection($users)->additional([
'queryParams' => $queryParams,
]),
]);
}
// Bulk delete methodpublicfunctionbulkDelete(Request$request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:users,id',
]);
User::whereIn('id', $request->ids)->delete();
returnredirect()->route('users.index')
->with('success', 'Users deleted successfully');
}
}<?phpnamespaceApp\Http\Resources;
useIlluminate\Http\Request;
useIlluminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
publicfunctiontoArray(Request$request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at->format('M d, Y'),
'updated_at' => $this->updated_at->format('M d, Y'),
// Add any other fields you need
];
}
}// routes/web.php
Route::delete('users/bulk-delete', [UserController::class, 'bulkDelete'])->name('users.bulk-delete');
Route::resource('users', UserController::class);// Main data structure returned from backendinterfacePaginatedData<T>{data: T[];queryParams: QueryParams;meta: PaginationMeta;links: SimplePaginationLinks;}// Query parameters for server requestsinterfaceQueryParams{search?: string;page?: number;per_page?: number;sort_by?: string|null;sort_dir?: 'asc'|'desc'|null;[key: string]: unknown;}// Bulk action definitioninterfaceBulkAction<TData>{label: string;icon?: LucideIcon|IconType|null;onClick: (selectedRows: TData[])=>void;className?: string;// For custom styling}// Pagination metadata from LaravelinterfacePaginationMeta{current_page: number;from: number;last_page: number;per_page: number;to: number;total: number;links: Array<{url: string|null;label: string;active: boolean;}>;}// resources/js/pages/users/index.tsximport{DataTable}from'@/components/datatable';import{Avatar,AvatarFallback,AvatarImage}from'@/components/ui/avatar';import{Badge}from'@/components/ui/badge';import{Button}from'@/components/ui/button';import{AlertDialog,AlertDialogAction,AlertDialogCancel,AlertDialogContent,AlertDialogDescription,AlertDialogFooter,AlertDialogHeader,AlertDialogTitle,AlertDialogTrigger,}from'@/components/ui/alert-dialog';import{ColumnDef}from'@tanstack/react-table';import{Eye,Pencil,Trash}from'lucide-react';constROLE_COLORS={admin: 'border-blue-500 text-blue-500',manager: 'border-green-500 text-green-500',user: 'border-gray-500 text-gray-500',};constUsersIndex=({ usersData }: {usersData: PaginatedData<User>})=>{consthandleDeleteUser=(userId: number)=>{router.delete(route('users.destroy',userId));};constcolumns: (ColumnDef<User>&{enable_sorting?: boolean})[]=[{accessorKey: 'id',header: '#ID',enable_sorting: true,cell: ({ row })=><div>#{row.original.id}</div>,},{header: 'Avatar',accessorKey: 'avatar',enable_sorting: false,cell: ({ row })=>(<AvatarclassName="size-10"><AvatarImagesrc={row.original.avatar}/><AvatarFallback>{row.original.name.charAt(0)}</AvatarFallback></Avatar>),},{accessorKey: 'name',header: 'Name',enable_sorting: true,cell: ({ row })=>(<div><h2className="text-base font-semibold">{row.original.name}</h2><pclassName="text-sm text-gray-500">{row.original.email}</p></div>),},{accessorKey: 'role',header: 'Role',enable_sorting: true,cell: ({ row })=>(<Badgevariant="outline"className={`capitalize ${ROLE_COLORS[row.original.roleaskeyoftypeofROLE_COLORS]}`}>{row.original.role}</Badge>),},{accessorKey: 'created_at',header: 'Created At',enable_sorting: true,},{header: 'Actions',accessorKey: 'actions',enable_sorting: false,cell: ({ row })=>(<divclassName="flex flex-row gap-0.5"><Buttonvariant="ghost"size="icon"className="size-8 text-blue-500"asChild><Linkhref={route('users.show',row.original.id)}><EyeclassName="size-4"/></Link></Button><Buttonvariant="ghost"size="icon"className="size-8 text-green-500"asChild><Linkhref={route('users.edit',row.original.id)}><PencilclassName="size-4"/></Link></Button><AlertDialog><AlertDialogTriggerasChild><Buttonvariant="ghost"size="icon"className="size-8 text-red-500"><TrashclassName="size-4"/></Button></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle><AlertDialogDescription>
This action cannot be undone. This will permanently delete the user "{row.original.name}".
</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogActiononClick={()=>handleDeleteUser(row.original.id)}className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div>),},];return(<DataTablecolumns={columns}data={usersData.data}paginatedData={usersData}activeBulkActions={true}bulkDelete={{route: route('users.bulk-delete'),title: 'Delete Users',description: 'Are you sure you want to delete the selected users? This action cannot be undone.',}}tableKey="users-table"/>);};The datatable uses Tailwind CSS classes and follows your existing design system. Key classes can be customized:
- Table container:
.rounded-md.border - Selected rows:
data-state="selected" - Toolbar:
.mb-3 - Pagination:
.mt-4
The search is debounced by 500ms and triggers when:
- Input length > 2 characters
- Input is cleared (length = 0)
To customize the debounce timing, modify the useDebouncedCallback in datatable-toolbar.tsx:
consthandleDebouncedSearch=useDebouncedCallback((value: string)=>{// Search logic},300);// Change from 500ms to 300msColumn visibility is automatically saved to localStorage using the tableKey prop. Each table should have a unique key:
<DataTabletableKey="users-table"// Unique identifier// ... other props/>Default page size options are defined in datatable-pagination.tsx:
constPER_PAGE_OPTIONS=[10,15,20,25,30,40,50,100];Extend the backend search to include more fields:
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('phone', 'like', '%' . $search . '%')
->orWhereHas('profile', function ($profile) use ($search) {
$profile->where('bio', 'like', '%' . $search . '%');
});
});
})Handle relationship sorting:
$allowedSorts = ['id', 'name', 'email', 'created_at', 'profile.company'];
if (in_array($queryParams['sort_by'], $allowedSorts)) {
if (str_contains($queryParams['sort_by'], '.')) {
// Handle relationship sorting
[$relation, $field] = explode('.', $queryParams['sort_by']);
$users->join($relation, 'users.id', '=', "{$relation}.user_id")
->orderBy("{$relation}.{$field}", $queryParams['sort_dir']);
} else {
$users->orderBy($queryParams['sort_by'], $queryParams['sort_dir']);
}
}Add error handling for failed requests:
// In your page componentconst[loading,setLoading]=useState(false);const[error,setError]=useState<string|null>(null);// Wrap router calls with error handlingconsthandleBulkAction=async(selectedRows: User[])=>{try{setLoading(true);setError(null);awaitrouter.delete(route('users.bulk-delete'),{data: {ids: selectedRows.map((row)=>row.id)},onError: (errors)=>{setError('Failed to delete users. Please try again.');},});}catch(err){setError('An unexpected error occurred.');}finally{setLoading(false);}};- Use Resource Collections: Always use Laravel Resource Collections to control exactly what data is sent to the frontend
- Limit Searchable Fields: Only search fields that are indexed in your database
- Optimize Queries: Use
select()to limit returned columns, eager load relationships - Debounced Search: The built-in 500ms debounce prevents excessive API calls
- Column Visibility: Hidden columns still receive data - consider conditional inclusion in your Resource
To extend the datatable functionality:
- Add new features to the appropriate component
- Update TypeScript interfaces in
types/index.d.ts - Add backend support if needed
- Update this documentation
- Test with the Users example
This project is licensed under the MIT License. See the LICENSE file for details.
✅ Commercial use
✅ Modification
✅ Distribution
✅ Private use
❌ Liability
❌ Warranty
Built with ❤️ using React, TanStack Table, Laravel, and Inertia.js

