npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-data-kit

v9.7.0

Published

A powerful table utility for server-side pagination, filtering, and sorting with React components

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, 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.