A powerful table utility for server-side pagination, filtering, and sorting with React hooks and components.
- 🚀 Server-side pagination - Efficient data fetching with page-based navigation
- 🔍 Flexible filtering - Support for regex, exact match, and custom filters
- 📊 Multi-column sorting - Sort by multiple columns with customizable order
- ♾️ Infinite scroll - DataKitInfinity component with pull-to-refresh support
- ⚛️ React hooks -
useDataKit,useSelection,usePaginationfor state management - 🎨 Components -
DataKitTablefor tables,DataKitfor custom layouts,DataKitInfinityfor feeds - 📝 TypeScript - Fully typed with generics support
- 🔌 Framework agnostic - Works with any database ORM/ODM (Mongoose, Prisma, etc.)
- 📦 Tree-shakeable - Import only what you need
npm install next-data-kit
# or
yarn add next-data-kit
# or
pnpm add next-data-kit'use server';
import { dataKitServerAction, createSearchFilter } from 'next-data-kit/server';
import type { TDataKitInput } from 'next-data-kit/types';
import UserModel from '@/models/User';
export async function fetchUsers(input: TDataKitInput) {
return dataKitServerAction({
model: UserModel,
input,
item: async user => ({
id: user._id.toString(),
name: user.name,
email: user.email,
}),
filterCustom: {
search: createSearchFilter(['name', 'email']),
age: value => ({ age: { $gte: value } }),
},
});
}You can use the built-in Zod schema to validate inputs before processing:
'use server';
## Styling
Next Data Kit ships with its own Tailwind CSS styles which are **automatically injected** into your application. You do not need to import any CSS files manually.
### Prefixing
To prevent class name conflicts with your application, all Next Data Kit utility classes are prefixed with `ndk:`. For example, instead of `flex`, components use `ndk:flex`.
If you need to override styles or use Data Kit's class names in your own custom components that interact with the library's internal state, remember to use the `ndk:` prefix.
### Tailwind Configuration (Optional)
Since styles are injected, you generally don't need to configure your Tailwind setup to be aware of Next Data Kit unless you are building custom components that rely on the library's internal theme variables.
import { dataKitServerAction, dataKitSchemaZod } from 'next-data-kit/server';
export async function fetchUsers(input: unknown) {
const parsedInput = dataKitSchemaZod.parse(input);
return dataKitServerAction({
model: UserModel,
input: parsedInput,
item: user => ({ id: user._id.toString(), name: user.name }),
filterCustom: {
search: value => ({ name: { $regex: value, $options: 'i' } }),
role: value => ({ role: value }),
},
});
}Ready-to-use table with built-in filtering, sorting, and selection:
'use client';
import { DataKitTable } from 'next-data-kit/client';
import { fetchUsers } from '@/actions/users';
export function UsersTable() {
return (
<DataKitTable
action={fetchUsers}
limit={{ default: 10 }}
filters={[
{ id: 'search', label: 'Search', type: 'TEXT', placeholder: 'Search...' },
{
id: 'role',
label: 'Role',
type: 'SELECT',
dataset: [
{ id: 'admin', name: 'admin', label: 'Admin' },
{ id: 'user', name: 'user', label: 'User' },
],
},
]}
selectable={{
enabled: true,
actions: {
export: {
name: 'Export',
icon: <Download className="mr-2 size-4" />,
function: async items => [true, {}],
},
sep1: { type: 'SEPARATOR' },
delete: {
name: 'Delete Selected',
icon: <Trash className="mr-2 size-4" />,
function: async items => {
await deleteUsers(items.map(i => i.id));
return [true, { deselectAll: true }];
},
},
},
}}
table={[
{
head: <DataKitTable.Head>Name</DataKitTable.Head>,
body: ({ item }) => <DataKitTable.Cell>{item.name}</DataKitTable.Cell>,
sortable: { path: 'name', default: 0 },
},
{
head: <DataKitTable.Head>Email</DataKitTable.Head>,
body: ({ item }) => <DataKitTable.Cell>{item.email}</DataKitTable.Cell>,
},
]}
sorts={[
{ path: '_id', value: -1 }, // Default sort by ID descending (consistent ordering)
]}
/>
);
}Use state and setState for per-row state (e.g., expanded rows, inline editing, loading states).
Note
Each row has its own independent state instance. The state prop defines the initial value, but each row maintains its own copy. Changing one row's state does not affect other rows.
'use client';
import { DataKitTable } from 'next-data-kit/client';
import { fetchUsers } from '@/actions/users';
export function UsersTable() {
return (
<DataKitTable
action={fetchUsers}
state={{ isExpanded: false, isEditing: false }}
table={[
{
head: <DataKitTable.Head>Name</DataKitTable.Head>,
body: ({ item, state, setState }) => (
<DataKitTable.Cell>
<div>{state.isEditing ? <input defaultValue={item.name} onBlur={() => setState(s => ({ ...s, isEditing: false }))} /> : <span onClick={() => setState(s => ({ ...s, isEditing: true }))}>{item.name}</span>}</div>
</DataKitTable.Cell>
),
},
{
head: <DataKitTable.Head>Actions</DataKitTable.Head>,
body: ({ item, state, setState }) => (
<DataKitTable.Cell>
<button onClick={() => setState(s => ({ ...s, isExpanded: !s.isExpanded }))}>{state.isExpanded ? 'Collapse' : 'Expand'}</button>
{state.isExpanded && <div className='mt-2 text-sm'>Details: {item.email}</div>}
</DataKitTable.Cell>
),
},
]}
/>
);
}Both DataKit and DataKitTable support two pagination modes:
// NUMBER (default) - Full numbered pagination with mobile responsiveness
<DataKitTable
action={fetchUsers}
pagination="NUMBER" // Default - shows page numbers
table={columns}
/>
// SIMPLE - Basic prev/next buttons only
<DataKitTable
action={fetchUsers}
pagination="SIMPLE"
table={columns}
/>NUMBER mode features:
- Desktop: Shows Previous, page numbers (1, 2, ... 10), Next
- Mobile: Shows prev icon, current page number, next icon
- Automatically adds ellipsis for skipped pages
- Fully responsive with Tailwind CSS
DataKitTable supports two types of sorting:
1. Column-based sorting - Interactive sorting via column headers:
table={[
{
head: <DataKitTable.Head>Name</DataKitTable.Head>,
body: ({ item }) => <DataKitTable.Cell>{item.name}</DataKitTable.Cell>,
sortable: {
path: 'name', // MongoDB field path
default: 1 // 1 (asc), -1 (desc), or 0 (no default)
}
}
]}2. Default sorts - Hidden sorts for consistent ordering:
<DataKitTable
action={fetchUsers}
table={columns}
sorts={[
{ path: '_id', value: -1 } // Sort by ID descending (tie-breaker)
]}
/>Sort priority and ordering:
MongoDB processes sorts in order. Column sorts take priority over default sorts:
<DataKitTable
table={[
{
head: <DataKitTable.Head>Priority</DataKitTable.Head>,
body: ({ item }) => <DataKitTable.Cell>{item.priority}</DataKitTable.Cell>,
sortable: { path: 'priority', default: -1 } // Sort #1: High priority first
}
]}
sorts={[
{ path: 'createdAt', value: -1 }, // Sort #2: Newest within same priority
{ path: '_id', value: -1 } // Sort #3: Consistent ordering
]}
/>
// Result: sorts = [{ path: 'priority', value: -1 }, { path: 'createdAt', value: -1 }, { path: '_id', value: -1 }]Dynamic Limit Options:
When you set a custom limit, it's automatically added to the dropdown:
<DataKitTable
action={fetchUsers}
limit={{ default: 15 }} // 15 will appear in dropdown alongside 10, 25, 50, 100
table={columns}
/>
// Works with any custom value
<DataKit
action={fetchUsers}
limit={{ default: 30 }} // Dropdown will show: 10, 25, 30, 50, 100
>
{dataKit => /* ... */}
</DataKit>Use DataKitInfinity for infinite scrolling feeds, chat interfaces, or any content that loads more as you scroll. No pagination controls - content loads automatically.
'use client';
import { DataKitInfinity } from 'next-data-kit/client';
import { fetchMessages } from '@/actions/messages';
export function MessagesFeed() {
return (
<DataKitInfinity action={fetchMessages} limit={{ default: 20 }} filters={[{ id: 'search', label: 'Search', type: 'TEXT' }]}>
{dataKit => (
<div className='space-y-4'>
{dataKit.items.map(message => (
<div key={message.id} className='rounded-lg border p-4'>
<p className='font-medium'>{message.author}</p>
<p>{message.content}</p>
</div>
))}
{!dataKit.state.hasNextPage && dataKit.items.length > 0 && <p className='text-center text-muted-foreground'>You're all set</p>}
{dataKit.state.isLoading && <p className='text-center text-muted-foreground'>Loading...</p>}
</div>
)}
</DataKitInfinity>
);
}Use DataKit for grids, cards, or any custom layout. It provides toolbar/pagination but lets you render content:
'use client';
import { DataKit } from 'next-data-kit/client';
import { fetchUsers } from '@/actions/users';
export function UsersGrid() {
return (
<DataKit action={fetchUsers} limit={{ default: 12 }} filters={[{ id: 'search', label: 'Search', type: 'TEXT' }]}>
{dataKit => (
<div className='grid grid-cols-4 gap-4'>
{dataKit.items.map(user => (
<div key={user.id} className='rounded-lg border p-4'>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
)}
</DataKit>
);
}Manual mode - handle loading/empty states yourself:
<DataKit action={fetchUsers} manual>
{dataKit => (
<>
{dataKit.state.isLoading && <Spinner />}
{dataKit.items.map(user => (
<Card key={user.id} user={user} />
))}
</>
)}
</DataKit>For fully custom implementations:
'use client';
import { useDataKit } from 'next-data-kit/client';
import { fetchUsers } from '@/actions/users';
export function UsersTable() {
const {
items,
page,
total,
state: { isLoading },
actions: { setPage, setFilter, setSort, refresh },
} = useDataKit({
action: fetchUsers,
initial: {
limit: 10,
},
});
return (
<div>
<input placeholder='Search...' onChange={e => setFilter('search', e.target.value)} />
{isLoading ? (
<p>Loading...</p>
) : (
<table>
<thead>
<tr>
<th onClick={() => setSort('name', 1)}>Name</th>
<th onClick={() => setSort('email', 1)}>Email</th>
</tr>
</thead>
<tbody>
{items.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
)}
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage(page + 1)}>Next</button>
</div>
);
}Main server function for handling table data fetching.
With Mongoose Model (auto-infers document type):
dataKitServerAction({
model: UserModel, // Mongoose model
input: TDataKitInput,
item: user => ({ ... }), // user is typed from model
filterCustom?: { ... }, // Custom filter handlers
filter?: { ... } | (input) => query, // Base filter (object or function)
defaultSort?: { ... },
maxLimit?: number, // Default: 100
queryAllowed?: string[], // Whitelist for query fields
filterAllowed?: string[], // Auto-derived from filterCustom
sortAllowed?: string[], // Whitelist for sortable fields
});Filter Options:
// As a plain object (static base filter)
filter: { isActive: true, deletedAt: null }
// As a function (dynamic filter based on input)
filter: (filterInput) => ({
organizationId: filterInput?.orgId,
isActive: true
})With Custom Adapter (for testing or non-mongoose):
import { adapterMemory } from 'next-data-kit/server';
dataKitServerAction({
adapter: adapterMemory(items), // or custom adapter
input: TDataKitInput,
item: item => ({ ... }),
maxLimit?: number,
queryAllowed?: string[],
filterAllowed?: string[],
sortAllowed?: string[],
});Three security whitelists:
-
filterCustom- User-facing filters (search, dropdowns, etc.)- Client
filtersprop → validated againstfilterCustomkeys - Only defined keys are allowed (throws error otherwise)
- Client
-
queryAllowed- Direct field matching (fixed filters)- Explicit whitelist required
- Use for:
{ active: true }, user-specific queries
-
sortAllowed- Sortable fields whitelist- Prevents sorting on arbitrary/sensitive fields
- Recommended for production security
dataKitServerAction({
model: UserModel,
input,
item: u => u,
filterCustom: {
search: createSearchFilter(['name', 'email']),
role: value => ({ role: value }),
},
queryAllowed: ['organizationId', 'active'],
sortAllowed: ['name', 'email', 'createdAt'], // Only allow sorting these fields
});Errors are automatically displayed in DataKitTable or available via state.error in useDataKit.
const {
state: { error },
} = useDataKit({ action: fetchUsers });
if (error) return <div>Error: {error.message}</div>;import { createSearchFilter, escapeRegex } from 'next-data-kit/server';
filterCustom: {
// Use built-in helper
search: createSearchFilter(['name', 'email', 'phone']),
// Or implement custom logic
priceRange: (value: { min: number; max: number }) => ({
price: { $gte: value.min, $lte: value.max },
}),
}Match client filter id with server filterCustom key:
// Client
<DataKitTable filters={[{ id: 'priceRange', label: 'Price', type: 'TEXT' }]} />
// Server
filterCustom: {
priceRange: value => ({ price: { $lte: Number(value) } }),
}
// Or use programmatically
const { actions: { setFilter } } = useDataKit({ ... });
setFilter('priceRange', 100);Full-featured table component with built-in UI.
| Prop | Type | Description |
|---|---|---|
action |
(input) => Promise<Result> |
Server action function |
table |
Column[] |
Column definitions |
filters |
FilterItem[] |
Filter configurations |
selectable |
{ enabled, actions? } |
Selection & bulk actions |
limit |
{ default: number } |
Items per page (auto-added to dropdown) |
sorts |
{ path, value }[] |
Default sorts (hidden fields like _id) |
defaultSort |
TSortEntry[] |
Initial sort configuration |
pagination |
'SIMPLE' | 'NUMBER' |
Pagination mode (default: 'NUMBER') |
controller |
Ref<Controller> |
External control ref |
className |
string |
Container class |
bordered |
boolean | 'rounded' |
Border style |
refetchInterval |
number |
Auto-refresh interval (ms) |
Controller Ref:
The controller prop allows external manipulation of the table. Pass a ref and access these methods:
import { useRef } from 'react';
import { DataKitTable } from 'next-data-kit/client';
import type { TDataKitController } from 'next-data-kit/types';
function MyTable() {
const controllerRef = useRef<TDataKitController<User> | null>(null);
const handleAddUser = () => {
controllerRef.current?.itemPush({ id: '123', name: 'New User' }, 0);
};
const handleUpdateUser = () => {
// Update by index
controllerRef.current?.itemUpdate({ index: 0, data: { name: 'Updated Name' } });
// Or update by id
controllerRef.current?.itemUpdate({ id: '123', data: { name: 'Updated Name' } });
};
const handleDeleteUser = () => {
// Delete by index
controllerRef.current?.itemDelete({ index: 0 });
// Or delete by id
controllerRef.current?.itemDelete({ id: '123' });
};
return <DataKitTable action={fetchUsers} controller={controllerRef} table={columns} />;
}Available methods:
itemPush(item, position?)- Add new item (0 = start, 1 = end)itemUpdate(props)- Update item by index or id with partial dataitemDelete(props)- Delete item by index or idrefetchData()- Refresh table data from serverdeleteBulk(items)- Delete multiple itemsgetSelectedItems()- Get currently selected itemsclearSelection()- Clear all selections
Headless component for custom layouts (grids, cards, etc).
| Prop | Type | Description |
|---|---|---|
action |
(input) => Promise<Result> |
Server action function |
filters |
FilterItem[] |
Filter configurations |
limit |
{ default: number } |
Items per page (auto-added to dropdown) |
defaultSort |
TSortEntry[] |
Initial sort configuration |
pagination |
'SIMPLE' | 'NUMBER' |
Pagination mode (default: 'NUMBER') |
manual |
boolean |
Skip loading/empty state handling |
children |
(dataKit) => ReactNode |
Render function |
Infinite scroll component for feeds, chat interfaces, and dynamic content loading.
| Prop | Type | Description |
|---|---|---|
action |
(input) => Promise<Result> |
Server action function |
filters |
FilterItem[] |
Filter configurations |
limit |
{ default: number } |
Items per page (default: 10) |
defaultSort |
TSortEntry[] |
Initial sort configuration |
manual |
boolean |
Skip loading/empty state handling |
autoFetch |
boolean |
Auto-fetch on mount (default: true) |
debounce |
number |
Filter debounce in ms (default: 300) |
memory |
'memory' | 'search-params' |
Memory management mode (default: 'memory') |
className |
string |
Container class |
children |
(dataKit) => ReactNode |
Render function with accumulated items |
Features:
- Automatically accumulates items across pages as user scrolls
- Uses
react-intersection-observerfor efficient scroll detection - Built-in toolbar with filters and manual refresh
- Access to
state.hasNextPagefor end-of-list detection
React hook for managing next-data-kit state.
interface TUseDataKitOptions<T, R> {
action: (input: TDataKitInput<T>) => Promise<TDataKitResult<R>>;
initial?: {
page?: number;
limit?: number;
sorts?: TSortEntry[];
filter?: Record<string, unknown>;
query?: Record<string, unknown>;
};
// ** Filter items with configuration
filters?: {
id: string;
configuration?: {
type: 'REGEX' | 'EXACT';
field?: string;
};
}[];
onSuccess?: (result: TDataKitResult<R>) => void;
onError?: (error: Error) => void;
autoFetch?: boolean;
}Returns:
items- Current page itemspage- Current page numberlimit- Items per pagetotal- Total document countsorts- Current sort configurationfilter- Current filter valuesstateisLoading- Loading stateerror- Error statehasNextPage- Whether more pages exist (page * limit < total)
actionssetPage(page)- Go to a specific pagesetLimit(limit)- Set items per pagesetSort(path, value)- Set sort for a columnsetFilter(key, value)- Set a filter valueclearFilters()- Clear all filtersrefresh()- Refresh the table datareset()- Reset to initial statesetItems(items)- Replace all itemssetItemAt(index, item)- Replace item at indexitemUpdate(props)- Update item by index or id with partial data- By index:
itemUpdate({ index: 0, data: { name: 'New Name' } }) - By id:
itemUpdate({ id: '123', data: { name: 'New Name' } })
- By index:
deleteItemAt(index)- Delete item at indexitemDelete(props)- Delete item by index or id- By index:
itemDelete({ index: 0 }) - By id:
itemDelete({ id: '123' })
- By index:
itemPush(item, position)- Add item (position: 0 = start, 1 = end)deleteBulk(items)- Delete multiple items
React hook for managing table row selection.
const { selectedIds, toggle, selectAll, deselectAll, isSelected, getSelectedArray } = useSelection<string>();React hook for calculating pagination state.
const { pages, hasNextPage, hasPrevPage, totalPages } = usePagination({
page: 1,
limit: 10,
total: 100,
});// TEXT - text input
{ id: "name", label: "Name", type: "TEXT", placeholder?: "..." }
// SELECT - dropdown (dataset required!)
{ id: "role", label: "Role", type: "SELECT", dataset: [{ id, name, label }] }
// BOOLEAN - toggle switch
{ id: "active", label: "Active", type: "BOOLEAN" }interface TDataKitInput<T = unknown> {
action?: 'FETCH';
page?: number;
limit?: number;
sort?: TSortOptions<T>;
sorts?: TSortEntry[];
query?: Record<string, unknown>;
filter?: Record<string, unknown>;
// ** Filter items with configuration
filters?: {
id: string;
configuration?: {
type: 'REGEX' | 'EXACT';
field?: string;
};
}[];
}interface TDataKitResult<R> {
type: 'ITEMS';
items: R[];
documentTotal: number;
}interface TFilterConfig {
[key: string]: {
type: 'REGEX' | 'EXACT';
field?: string;
};
}Use custom adapters for non-mongoose databases or testing:
import { adapterMemory } from 'next-data-kit/server';
import type { TDataKitAdapter } from 'next-data-kit/types';
// Built-in memory adapter (great for testing)
const adapter = adapterMemory(items);
// Or create a custom adapter
const myAdapter: TDataKitAdapter<MyDocument> = async ({ filter, sorts, limit, skip }) => {
const items = await myDb.query({ filter, limit, skip });
const total = await myDb.count(filter);
return { items, total };
};
dataKitServerAction({
adapter: myAdapter,
input,
item: doc => ({ ... }),
});MIT © muhgholy
This repo includes a real Next.js playground demonstrating all features with MongoDB.
cd playground
npm install
npm run seed # Seed MongoDB with sample data
npm run dev # Start Next.js dev serverThen open: http://localhost:3000
Prerequisites: MongoDB running on mongodb://localhost:27017
See playground/README.md for details.