next-data-kit
v9.7.0
Published
A powerful table utility for server-side pagination, filtering, and sorting with React components
Maintainers
Readme
next-data-kit
A powerful table utility for server-side pagination, filtering, and sorting with React hooks and components.
Features
- 🚀 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
Installation
npm install next-data-kit
# or
yarn add next-data-kit
# or
pnpm add next-data-kitQuick Start
Server-side (Next.js Server Action)
'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 } }),
},
});
}Input Validation (Optional)
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 }),
},
});
}Client-side (DataKitTable Component)
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)
]}
/>
);
}Row State Management
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
stateprop 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>
),
},
]}
/>
);
}Pagination Modes
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
Sorting
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>Client-side (DataKitInfinity Component - Infinite Scroll)
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>
);
}Client-side (DataKit Component - Custom Layout)
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>Client-side (useDataKit Hook)
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>
);
}API Reference
Server
dataKitServerAction(options)
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[],
});Security & Filtering
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
});Error Handling
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>;Custom Filters
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 },
}),
}Filter Flow
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);Client
<DataKitTable> Component
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
<DataKit> Component
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 |
<DataKitInfinity> Component
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
useDataKit(options)
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
useSelection<T>()
React hook for managing table row selection.
const { selectedIds, toggle, selectAll, deselectAll, isSelected, getSelectedArray } = useSelection<string>();usePagination(options)
React hook for calculating pagination state.
const { pages, hasNextPage, hasPrevPage, totalPages } = usePagination({
page: 1,
limit: 10,
total: 100,
});Types
Filter Types (Discriminated Union)
// 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" }TDataKitInput<T>
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;
};
}[];
}TDataKitResult<R>
interface TDataKitResult<R> {
type: 'ITEMS';
items: R[];
documentTotal: number;
}TFilterConfig
interface TFilterConfig {
[key: string]: {
type: 'REGEX' | 'EXACT';
field?: string;
};
}Custom Adapters
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 => ({ ... }),
});License
MIT © muhgholy
Dev Playground (Next.js + MongoDB)
This repo includes a real Next.js playground demonstrating all features with MongoDB.
Run Playground
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.
