Skip to content

muhgholy/next-data-kit

Repository files navigation

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, usePagination for state management
  • 🎨 Components - DataKitTable for tables, DataKit for custom layouts, DataKitInfinity for 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-kit

Quick 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 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>
					),
				},
			]}
		/>
	);
}

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:

  1. filterCustom - User-facing filters (search, dropdowns, etc.)

    • Client filters prop → validated against filterCustom keys
    • Only defined keys are allowed (throws error otherwise)
  2. queryAllowed - Direct field matching (fixed filters)

    • Explicit whitelist required
    • Use for: { active: true }, user-specific queries
  3. 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 data
  • itemDelete(props) - Delete item by index or id
  • refetchData() - Refresh table data from server
  • deleteBulk(items) - Delete multiple items
  • getSelectedItems() - Get currently selected items
  • clearSelection() - 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-observer for efficient scroll detection
  • Built-in toolbar with filters and manual refresh
  • Access to state.hasNextPage for 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 items
  • page - Current page number
  • limit - Items per page
  • total - Total document count
  • sorts - Current sort configuration
  • filter - Current filter values
  • state
    • isLoading - Loading state
    • error - Error state
    • hasNextPage - Whether more pages exist (page * limit < total)
  • actions
    • setPage(page) - Go to a specific page
    • setLimit(limit) - Set items per page
    • setSort(path, value) - Set sort for a column
    • setFilter(key, value) - Set a filter value
    • clearFilters() - Clear all filters
    • refresh() - Refresh the table data
    • reset() - Reset to initial state
    • setItems(items) - Replace all items
    • setItemAt(index, item) - Replace item at index
    • itemUpdate(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' } })
    • deleteItemAt(index) - Delete item at index
    • itemDelete(props) - Delete item by index or id
      • By index: itemDelete({ index: 0 })
      • By id: itemDelete({ id: '123' })
    • 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 server

Then open: http://localhost:3000

Prerequisites: MongoDB running on mongodb://localhost:27017

See playground/README.md for details.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •