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

@nexgrid/react

v0.2.1

Published

TableX for React and Next.js — a professional, server-driven data grid with search, sorting, selection, density, exports, and responsive card layout.

Readme

@nexgrid/react

A server-driven data grid for React and Next.js.

TableX renders one page of rows at a time and never holds the dataset. Every piece of user intent — page, page size, sort, search, filters — is expressed as a single QueryState object that you own; the grid hands you the next one and re-renders when you hand back the matching page. That is the whole contract. There is no local sort that quietly reorders 10 rows out of 40,000, and no client-side filter that hides records the total still counts.

Around that core it provides the things every real admin table ends up needing:

  • 🗂️ Column Header Grouping (Multi-Level / Stacked Headers) — Group sub-columns beneath parent categories with automatic colSpan and rowSpan calculation.

  • 🚀 Client-Side Pagination & In-Memory Engine — Zero-config in-memory paging, sorting, search, filtering, and export over local arrays.

  • 💾 Grid State Persistence (storageKey) — Automatically saves column widths, column order, hidden columns, and row density to localStorage.

  • ↔️ Column Resizing & Auto-Fit — Interactive drag resize handles and double-click auto-fit measuring.

  • 🏷️ Active Filter Pills Bar — Interactive chip badges beneath the toolbar for active search & column filters with one-click removal and "Clear all".

  • 🔍 Debounced global search (350 ms), 3-state sorting cycle (asc → desc → cleared), multi-column sorting.

  • 📌 Pinned columns (left / right freeze), master-detail accordion row expansion, summary/aggregation footer row.

  • 📊 Formatted Excel (.xls) and CSV export, including whole-dataset export across pages.

  • 📱 Responsive layout — Full desktop table at ≥ 768 px, smart card list below.

  • 💛 Dual Language Support — 100% compatible with both TypeScript (TSX) and plain JavaScript (JSX) with JSDoc typing.

  • Zero runtime dependencies beyond @nexgrid/core. React is a peer dependency.

  • Ships ESM and CJS, with a "use client" banner so it drops straight into the Next.js App Router.

Installation

npm install @nexgrid/react @nexgrid/core

Import the stylesheet once, anywhere in your app:

import "@nexgrid/react/styles.css";

Quick start

The grid is fully controlled. Hold a QueryState in state, fetch whenever it changes, and pass the result straight through.

"use client";

import { useCallback, useEffect, useState } from "react";
import {
  TableX,
  defaultQuery,
  serializeQuery,
  type TableXReactColumn,
  type PagedResponse,
  type QueryState,
} from "@nexgrid/react";
import "@nexgrid/react/styles.css";

interface Student {
  id: number;
  name: string;
  email: string;
  status: "Active" | "Pending" | "Disabled";
  joinedAt: string;
}

const columns: TableXReactColumn<Student>[] = [
  { accessorKey: "name", header: "Name", meta: { minWidth: 180 } },
  { accessorKey: "email", header: "Email" },
  {
    accessorKey: "status",
    header: "Status",
    meta: { align: "center", width: 130 },
    cell: ({ getValue }) => {
      const status = String(getValue());
      return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
    },
  },
  {
    accessorKey: "joinedAt",
    header: "Joined",
    cell: ({ getValue }) => new Date(String(getValue())).toLocaleDateString(),
  },
];

export function StudentsGrid() {
  const [query, setQuery] = useState<QueryState>(defaultQuery());
  const [page, setPage] = useState<PagedResponse<Student> | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(false);

  const load = useCallback(async (next: QueryState) => {
    setIsLoading(true);
    setError(false);
    try {
      // serializeQuery produces ?page=2&pageSize=25&sort=name:asc&q=smith
      const response = await fetch(`/api/students?${serializeQuery(next)}`);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      setPage((await response.json()) as PagedResponse<Student>);
    } catch {
      setError(true);
    } finally {
      setIsLoading(false);
    }
  }, []);

  useEffect(() => {
    void load(query);
  }, [load, query]);

  return (
    <TableX
      caption="Students"
      columns={columns}
      data={page?.items ?? []}
      total={page?.total ?? 0}
      query={query}
      onQueryChange={setQuery}
      isLoading={isLoading}
      error={error}
      onRetry={() => void load(query)}
      enableSelection
      onSelectionChange={(ids) => console.log("selected", ids)}
      // Lets an export page in the whole filtered dataset, not just this page.
      fetchEndpoint="/api/students"
      // The grid never renders toasts — forward these to your own.
      onNotify={({ type, message }) => console.info(type, message)}
    />
  );
}

Your endpoint must answer with a PagedResponse<T>:

{ "items": [], "page": 1, "pageSize": 10, "total": 0, "totalPages": 1 }

If your API is ASP.NET Core, TableX.AspNetCore binds exactly the query string serializeQuery produces and returns exactly this shape.

Putting the query in the URL

QueryState round-trips through a query string, so making the grid shareable and back-button-friendly is a swap of the state hook:

const searchParams = useSearchParams();
const router = useRouter();
const query = useMemo(() => parseQuery(searchParams.toString()), [searchParams]);

<TableX
  query={query}
  onQueryChange={(next) => router.replace(`?${serializeQuery(next)}`)}
  {...rest}
/>;

parseQuery degrades safely — a bad page becomes 1, an unknown page size becomes the default, malformed sort tokens are dropped — so a hand-edited URL can never put the grid into an impossible state.

Next.js App Router

The published bundle starts with "use client", so <TableX /> can be imported directly from a Server Component without a wrapper:

// app/students/page.tsx — a Server Component
import { StudentsGrid } from "./students-grid";

export default async function Page() {
  return <StudentsGrid />;
}

Two notes:

  • Anything you pass through props still crosses the server/client boundary, so columns (which contains cell functions) must be defined in a file marked "use client", not in the server page.
  • Import @nexgrid/react/styles.css from your root layout, or from the client component itself if your setup supports component-level CSS imports.

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | columns | TableXReactColumn<TData>[] | required | Column definitions, in display order (supports multi-level columns). | | data | TData[] | required | The current page of rows, or the entire array if clientSidePagination is true. | | total | number | optional in client mode | Total filtered row count from the server. Drives the pager. | | query | QueryState | optional in client mode | The query the data above answers. | | onQueryChange | (next: QueryState) => void | optional in client mode | Called with the next query on page / size / sort / search changes. | | clientSidePagination | boolean | false | Enables zero-config in-memory paging, sorting, search, and filtering over local data. | | storageKey | string | — | Persists custom column widths, column order, hidden columns, and density to localStorage. | | caption | string | required | Accessible name for the table; also default export filename and sheet title. | | density | "compact" \| "default" \| "comfortable" | "default" | Initial density preset. | | enableColumnResize | boolean | true | Enables interactive drag-to-resize and double-click auto-fit on column borders. | | enableColumnFilters | boolean | true | Enables 3-dot column filter popovers (⋮) for text, select, and range filters. | | enableSorting | boolean | true | Global switch for column sorting. | | enableSummaryRow | boolean | false | Renders a summary / aggregation row in <tfoot>. | | enableRowExpansion | boolean | false | Master-detail accordion expandable sub-rows. | | renderExpandedRow | (row: TData) => ReactNode | — | Render function for expanded row content. | | enableBulkActions | boolean | false | Floating bottom pill bar for batch operations on selected rows. | | isLoading | boolean | false | Replaces rows with a spinner while keeping toolbar and footer interactive. | | error | boolean | false | Replaces the whole grid with an accessible error card. | | onRetry | () => void | — | When set, the error card displays a retry button. | | enableSelection | boolean | false | Renders row selection checkboxes. | | selectionMode | "multi" \| "single" | "multi" | Whether multiple rows or only one row can be selected. | | onSelectionChange | (ids: string[]) => void | — | Fires after each selection change. | | enableSearch | boolean | true | Shows the debounced global search box. | | searchPlaceholder | string | locale.searchPlaceholder | Placeholder text for the search box. | | toolbarActions | ReactNode | — | Custom actions rendered at the end of the toolbar. | | onRowClick | (row: TData) => void | — | Row / card click handler. Adds pointer cursor and keyboard activation. | | getRowId | (row: TData) => string | String(row.id ?? row) | Stable row identity, used for selection and React keys. | | showSerialNumber | boolean | true | Shows automatic S.No. column, numbered across pages. | | enableExport | boolean | true | Shows the export menu (Excel, CSV, Clipboard). | | exportFileName | string | caption, slug-safe | File name prefix without extension. | | fetchEndpoint | string | — | Endpoint used to page in the full dataset during export. | | badgeRules | readonly ExcelBadgeRule[] | core's DEFAULT_BADGE_RULES | Value-based cell styling for Excel export. | | locale | Partial<TableXLocale> | English defaults | Overrides for any user-facing string. | | onNotify | (notice: TableXNotice) => void | no-op | Receives { type, message } for notifications. | | theme | "light" \| "dark" \| "auto" | "light" | Sets .tbx-dark / .tbx-auto theme mode. |

Column definitions

A column is a plain object, structurally compatible with TanStack Table's ColumnDef — existing column sets usually work unchanged.

| Field | Type | Description | |-------|------|-------------| | id | string | Column id. Falls back to accessorKey. | | accessorKey | string | The row property this column reads. | | header | string \| (ctx) => ReactNode | Header content. A string is also used for menus and export headers. | | columns | TableXReactColumn<TData>[] | Nested sub-columns for multi-level stacked column header groups. | | cell | (ctx: { row: { original: TData }, getValue(): unknown }) => ReactNode | Custom cell renderer. Without it the raw value is rendered as text. | | enableSorting | boolean | Sorting is on by default; set false to opt out. | | meta | TableXColumnMeta | Layout and behavior hints — see below. |

meta

| Field | Type | Description | |-------|------|-------------| | width | number | Fixed pixel width. | | minWidth | number | Minimum width in pixels. Defaults to 120 when no width is set. | | align | "left" \| "center" \| "right" | Header and cell alignment. | | hidden | boolean | Start hidden. Still listed in the Columns menu. | | hideable | boolean | Set false to keep the column out of the Columns menu. | | exportable | boolean | Set false to keep the column out of CSV/Excel exports. | | serverFilterable, serverFilterField, filterOptions | — | Declare a column as server-filterable (filter[field]=value). |

Two ids are structural: select and actions. They are never sortable, never hideable, and never exported.

Custom cells

cell returns any ReactNode, and the same renderer is used by the table and the mobile card list, so the two can never drift apart.

const columns: TableXReactColumn<Student>[] = [
  // A status pill.
  {
    accessorKey: "status",
    header: "Status",
    meta: { align: "center", width: 130 },
    cell: ({ getValue }) => {
      const status = String(getValue());
      return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
    },
  },

  // Composed from more than one field — reach through `row.original`.
  {
    id: "student",
    header: "Student",
    cell: ({ row }) => (
      <div className="stack">
        <strong>{row.original.name}</strong>
        <small>{row.original.email}</small>
      </div>
    ),
  },

  // A row action menu. `actions` is structural: unsortable and never exported.
  {
    id: "actions",
    header: "",
    meta: { align: "right", width: 64 },
    cell: ({ row }) => (
      <button type="button" onClick={(event) => event.stopPropagation()}>
        Edit
      </button>
    ),
  },
];

Two things worth knowing:

  • Exports read the underlying row value, not the rendered cell. A custom cell is presentation; the status column above exports Active, not the markup of the pill. Set meta.exportable: false on columns that carry no data.
  • When onRowClick is set, call event.stopPropagation() in interactive cell content so a button click does not also open the row. Selection checkboxes already do this for you.

Theming

Every color and shape in the stylesheet reads a CSS custom property, so you re-skin the grid by overriding tokens — no class overrides, no !important.

.tbx-root {
  --tbx-primary: #7c3aed;
  --tbx-primary-fg: #ffffff;
  --tbx-radius: 8px;
  --tbx-font: "Inter", system-ui, sans-serif;
}

| Token | Purpose | |-------|---------| | --tbx-font, --tbx-font-mono | Body font, and the serial-number font. | | --tbx-bg | Input and pager background. | | --tbx-card, --tbx-card-2 | Panel background, and the table header band. | | --tbx-border | Every border and divider. | | --tbx-fg, --tbx-muted-fg | Primary and secondary text. | | --tbx-muted | Hover fills and subtle chips. | | --tbx-primary, --tbx-primary-fg | Accent: sort icons, current page, selection. | | --tbx-danger | Destructive accents. | | --tbx-radius, --tbx-radius-sm | Panel and control corner radii. | | --tbx-shadow, --tbx-focus-ring | Elevation, and the focus ring. |

Dark mode is a class, not a media query, so it can follow whatever your app already uses:

<TableX theme="dark" {...props} />   {/* always dark          */}
<TableX theme="auto" {...props} />   {/* follows the OS       */}

theme="dark" puts .tbx-dark on the grid root. If your app already toggles a dark class higher up the tree, add tbx-dark alongside it and leave theme alone — the stylesheet matches .tbx-dark .tbx-root as well.

Responsive behavior is driven entirely by the stylesheet: the grid renders both a table and a card list, and CSS shows the table at ≥ 768px and the cards below it.

Exporting

The export menu offers a formatted Excel workbook (.xls, with colored status badges) and a raw CSV (RFC 4180, UTF-8 BOM, with spreadsheet-formula injection neutralized). Both write the visible columns, minus anything marked meta.exportable: false.

By default an export contains the current page. Pass fetchEndpoint and the grid will page through the rest of the filtered dataset first, preserving the active search, sort, and filters, at 100 rows per request up to a 2,000-row safety cap. If those requests fail it notifies you and falls back to the current page rather than producing nothing.

<TableX
  fetchEndpoint="/api/students"
  exportFileName="student_roster"
  badgeRules={[
    { values: ["Active"], background: "#dcfce7", color: "#15803d" },
    { values: ["Disabled"], background: "#fee2e2", color: "#b91c1c" },
  ]}
  onNotify={({ type, message }) => toast[type](message)}
  {...props}
/>

To export server-side instead — a real .xlsx, a background job, a signed download URL — pass onExportAll. It replaces the built-in flow completely.

Notifications

The grid never renders toasts. A toast belongs to your design system, and two competing toast stacks in one page is a worse bug than no toast at all. Anything the grid wants to say arrives at onNotify as { type, message } where type is "info" | "success" | "error", ready to forward to whatever you already use.

Localization

Every user-facing string comes from a locale object. Override any subset:

<TableX
  locale={{
    searchPlaceholder: "Rechercher…",
    emptyText: "Aucun enregistrement ne correspond à votre recherche.",
    showingRange: "Affichage de {start} à {end} sur {total} entrées",
    rowsPerPage: "Lignes :",
  }}
  {...props}
/>

Templates keep their {placeholder} tokens so word order stays yours. Import DEFAULT_LOCALE from this package to see every key.

Accessibility

  • aria-label={caption} on the table; every icon-only control has an accessible name drawn from the locale.
  • Sortable headers are focusable, activate on Enter or Space, and carry aria-sort (ascending / descending / none).
  • Menus are role="menu" with role="menuitemcheckbox" and aria-checked items; they close on outside click and on Escape, which returns focus to the trigger.
  • The header checkbox reports the mixed state when only part of a page is selected.
  • The current page button carries aria-current="page".
  • When onRowClick is set, rows and cards become focusable and activate on Enter. Keep genuinely interactive content in a cell rather than relying on the row handler alone.

Re-exported from @nexgrid/core

For convenience, the pieces a host needs to drive a controlled grid are re-exported from this package, so most apps never import @nexgrid/core directly:

defaultQuery, parseQuery, serializeQuery, buildQueryUrl, primarySort, withToggledSort, withSort, withSearch, withPage, withPageSize, withFilter, totalPagesFor, isPageSize, PAGE_SIZES, DEFAULT_PAGE_SIZE, DEFAULT_LOCALE, resolveLocale.

Always mutate a QueryState through those reducers rather than spreading it by hand — they are what guarantee that a search or a page-size change resets to page one and that the sort cycle stays asc → desc → cleared across every adapter.

Author & Maintainer

Chhagan Sinha

License

MIT © 2026 Chhagan Sinha