Step-by-step setup with a concrete Next.js example
Provider & Setup
DataTable is the single entry point. You pass configuration and actions as props, and DataTable wires TableProvider internally.
Breaking change: YaYaw Table no longer creates an internal QueryClient. You must provide a shared TanStack Query client through
QueryClientProvider(recommended) or pass a sharedqueryClientinstance explicitly.
This page shows a concrete setup you can copy, then adapt.
Step-by-step setup (Next.js App Router)
1. Add NuqsAdapter in your root layout
If you want sort, filters, pagination, and visibility persisted in URL params, wrap your app with NuqsAdapter:
// app/layout.tsx
import { NuqsAdapter } from "nuqs/adapters/next/app";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
);
}2. Create your table actions (minimum: list)
The table calls your actions for data and CRUD. The only required action is list.
// app/products/actions/products.ts
"use server";
type ListProductsParams = {
advancedFilters?: unknown[];
filters?: Record<string, unknown>;
limit?: number;
orderBy?: Record<string, "asc" | "desc">;
page?: number; // 1-based
search?: string;
};
const products = [
{ id: "1", name: "MacBook Pro", brand: "Apple", price: 2499, isActive: true },
{ id: "2", name: "ThinkPad X1", brand: "Lenovo", price: 1899, isActive: true },
{ id: "3", name: "XPS 13", brand: "Dell", price: 1599, isActive: false },
];
export async function listProducts(params: ListProductsParams) {
const { limit = 10, page = 1, search = "" } = params;
const filtered = products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
);
const start = (page - 1) * limit;
const end = start + limit;
const paged = filtered.slice(start, end);
return {
data: paged,
meta: {
pageCount: Math.max(1, Math.ceil(filtered.length / limit)),
totalCount: filtered.length,
},
};
}
export async function createProduct(data: Record<string, unknown>) {
return { success: true, data };
}
export async function updateProduct(id: string, data: Record<string, unknown>) {
return { success: true, data: { id, ...data } };
}
export async function deleteProduct(id: string) {
return { success: true, data: { id } };
}3. Create getTableConfig and getTableActions
Keep setup in one file so each tableType is easy to maintain:
// app/products/setup/table-config.ts
import {
createProduct,
deleteProduct,
listProducts,
updateProduct,
} from "../actions/products";
export const getTableConfig = (tableType: string) => {
if (tableType !== "products") {
return;
}
return {
defaultPageSize: 10,
enableColumnDnd: true,
enableColumnDragDropByDefault: false,
enableColumnFilters: true,
enableGrouping: false,
enableMultiRowSelection: true,
enablePagination: true,
enableRowDragDrop: false,
enableRowClickEdit: false,
enableRowSelection: true,
enableSorting: true,
pageSizeOptions: [10, 20, 50],
columns: {
definitions: [
{ id: "name", type: "text", header: "Name", enableSorting: true, enableColumnFilter: true },
{ id: "brand", type: "text", header: "Brand", enableSorting: true, enableColumnFilter: true },
{ id: "price", type: "number", header: "Price", enableSorting: true, enableColumnFilter: true },
{ id: "isActive", type: "boolean", header: "Active", enableSorting: true, enableColumnFilter: true },
{ id: "actions", type: "actions", header: "Actions", enableSorting: false, enableColumnFilter: false },
],
order: ["select", "name", "brand", "price", "isActive", "actions"],
visible: ["select", "name", "brand", "price", "isActive", "actions"],
mandatory: ["name"],
sort: [{ id: "name", desc: false }],
},
translations: {
namespace: "table.products",
keys: {
title: "Products",
description: "Manage your product catalog",
},
},
};
};
export const getTableActions = (tableType: string) => {
if (tableType !== "products") {
return;
}
return {
list: listProducts,
create: createProduct,
update: updateProduct,
delete: deleteProduct,
};
};4. Render DataTable inside a shared QueryClientProvider
getTableConfig and getTableActions are functions, so the component that passes them must be a Client Component.
// app/products/page.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DataTable } from "@/components/ui/yayaw-table";
import { getTableActions, getTableConfig } from "./setup/table-config";
const queryClient = new QueryClient();
export default function ProductsPage() {
return (
<QueryClientProvider client={queryClient}>
<DataTable
tableType="products"
getTableConfig={getTableConfig}
getTableActions={getTableActions}
title="Products"
description="Server-side pagination, filtering and sorting"
/>
</QueryClientProvider>
);
}5. Optional: add getFormConfig for create/edit dialogs
If you want built-in create/edit/bulk-edit forms, provide getFormConfig:
// app/products/setup/form-config.ts
export const getFormConfig = (formType: string) => {
if (formType !== "products") {
return;
}
return {
id: "products",
fields: [
{ type: "text", name: "name", label: "Name", required: true },
{ type: "number", name: "price", label: "Price", required: true },
{ type: "switch", name: "isActive", label: "Active" },
],
};
};Then pass it:
<DataTable
tableType="products"
getTableConfig={getTableConfig}
getTableActions={getTableActions}
getFormConfig={getFormConfig}
/>Setup checklist
After the 4 core steps above, your table should:
- Load data through
list(params). - Keep sorting, filters, and pagination in URL params.
- Call
create/update/deletewhen row actions are used. - Use your columns/order/visibility from
getTableConfig.
getTableConfig
getTableConfig(tableType) should return:
- Top-level table behavior keys like
enableRowSelection,enableRowClickEdit,enableSorting,enableColumnFilters,defaultPageSize,pageSizeOptions,export,bulkExport,actionsAsIcons. enableRowClickEditopens the edit drawer on row click and cannot be combined withurlDisplayMode: "row-link"or inline edit.- columns –
definitions(array of{ id, type, header, enableSorting?, enableColumnFilter? }),order,visible,mandatory,sort. - translations (optional) –
namespace,keysfor title/description.
See Configuration and Columns for full options.
Filtering, pagination, and sorting are server-side in YaYaw Table.
getTableActions
Return an object with methods keyed by action name. The table calls these when the user sorts, filters, paginates, or runs CRUD/bulk actions.
- list – Required for server-driven data. Signature:
(params) => Promise<{ data, meta: { pageCount, totalCount } }>. Params includefilters,advancedFilters,limit,page(1-based),orderBy,search. - create, update, delete, duplicate – Optional; used for row actions and default bulk behavior.
- bulkDelete, bulkCopy, bulkUpdate – Optional; used when user runs bulk actions.
See Actions and Server-side & Server Actions.
getFormConfig
Return form configuration per form type (e.g. "products", "products-bulk"). Used to build create/edit forms and bulk edit. Shape: fields array with name, type, label, required, etc. See Forms and Collection Fields for the full form field model, including native array editors.
Boolean fields: In catalog forms, boolean fields render as a Switch by default (type: "switch" or type: "checkbox"). If you specifically want a checkbox UI, set variant: "checkbox" on the field definition.
{
type: "switch",
name: "isActive",
label: "Active",
}Collection fields
Use type: "collection" when a form value is an array of objects and users need to add, edit, delete, reorder, and validate items without building a custom editor. The collection stays controlled by the form value: YaYaw Table writes the next array through the field API, and it does not keep an opaque copy of your data.
For a deeper explanation of what this enables, how nested collections work, and how to choose between collection and custom, see Forms and Collection Fields.
import { defineFormConfig } from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { z } from "zod";
const FeatureSchema = z.object({
features: z.array(
z.object({
label: z.string().min(1),
enabled: z.boolean(),
})
),
});
export const featureForm = defineFormConfig({
id: "features",
schema: FeatureSchema,
defaultValues: {
features: [],
},
fields: [
{
type: "collection",
name: "features",
label: "Features",
description: "Manage the product feature list.",
addLabel: "Add feature",
itemLabel: "feature",
emptyLabel: "No features yet.",
columns: [
{ id: "label", header: "Label" },
{
id: "enabled",
header: "Enabled",
render: (item) => (item.enabled ? "Yes" : "No"),
},
],
createItem: () => ({ label: "", enabled: true }),
renderItemForm: ({ item, onChange, disabled }) => (
<div className="space-y-3">
<Input
disabled={disabled}
onChange={(event) =>
onChange({ ...item, label: event.target.value })
}
value={String(item.label ?? "")}
/>
<Switch
checked={item.enabled === true}
disabled={disabled}
onCheckedChange={(enabled) => onChange({ ...item, enabled })}
/>
</div>
),
validateItem: (item) =>
typeof item.label === "string" && item.label.length > 0
? []
: ["Label is required"],
validateItems: (items) =>
items.length > 0 ? [] : ["Add at least one feature"],
},
],
});validateItem marks invalid rows, validateItems shows global errors, and both validators are also attached to the TanStack Form field so an invalid collection blocks submit. Zod validation still runs through the form schema, so schema errors and collection-specific errors can be used together.
For menu-like JSON, use createActions to create several item shapes and CollectionEditor inside a group item form to edit a nested one-level collection.
import {
CollectionEditor,
defineFormConfig,
} from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";
import { z } from "zod";
const createLink = () => ({
type: "link",
label: "",
href: "",
placement: "primary",
variant: "default",
description: "",
external: false,
});
const menuItemColumns = [
{ id: "label", header: "Label" },
{ id: "type", header: "Type" },
{ id: "placement", header: "Placement" },
];
export const menuForm = defineFormConfig({
id: "menu",
schema: z.object({
items_json: z.array(z.record(z.string(), z.unknown())),
}),
defaultValues: {
items_json: [],
},
fields: [
{
type: "collection",
name: "items_json",
label: "Menu items",
addLabel: "Add item",
itemLabel: "menu item",
emptyLabel: "No menu items yet.",
columns: menuItemColumns,
createItem: createLink,
createActions: [
{ label: "Add link", createItem: createLink },
{
label: "Add group",
createItem: () => ({
type: "group",
label: "",
description: "",
placement: "primary",
items: [],
}),
},
{
label: "Add theme toggle",
createItem: () => ({ type: "themeToggle", placement: "utility" }),
},
{
label: "Add language toggle",
createItem: () => ({ type: "languageToggle", placement: "utility" }),
},
],
getItemKey: (item, index) => String(item.id ?? `${item.type}-${index}`),
renderItemForm: ({ item, onChange, disabled }) => {
if (item.type === "group") {
return (
<div className="space-y-4">
<Input
disabled={disabled}
onChange={(event) =>
onChange({ ...item, label: event.target.value })
}
value={String(item.label ?? "")}
/>
<CollectionEditor
addLabel="Add nested link"
columns={[
{ id: "label", header: "Label" },
{ id: "href", header: "Href" },
]}
createItem={createLink}
disabled={disabled}
itemLabel="nested link"
label="Nested links"
onChange={(items) => onChange({ ...item, items })}
renderItemForm={({ item: nestedItem, onChange: onNestedChange }) => (
<div className="space-y-3">
<Input
onChange={(event) =>
onNestedChange({
...nestedItem,
label: event.target.value,
})
}
value={String(nestedItem.label ?? "")}
/>
<Input
onChange={(event) =>
onNestedChange({
...nestedItem,
href: event.target.value,
})
}
value={String(nestedItem.href ?? "")}
/>
</div>
)}
validateItem={(nestedItem) =>
nestedItem.type === "link" ? [] : ["Only links are allowed"]
}
value={item.items}
/>
</div>
);
}
return (
<div className="space-y-3">
<Input
disabled={disabled}
onChange={(event) =>
onChange({ ...item, label: event.target.value })
}
value={String(item.label ?? "")}
/>
<Input
disabled={disabled}
onChange={(event) =>
onChange({ ...item, href: event.target.value })
}
value={String(item.href ?? "")}
/>
</div>
);
},
validateItem: (item) => {
if (item.type === "group") {
const nestedItems = Array.isArray(item.items) ? item.items : [];
return nestedItems.every(
(nestedItem) =>
typeof nestedItem === "object" &&
nestedItem !== null &&
"href" in nestedItem
)
? []
: ["Group items must be links"];
}
if (item.type === "link" && !item.href) {
return ["Href is required"];
}
return [];
},
},
],
});The built-in action labels (Actions, Cancel, Save, row edit/delete/move labels) can be customized with labels or translated with labelKeys on the collection field.
Form translations: The catalog form uses the same DataTableTranslations object as the table. You must add form and value keys to that object (e.g. form.name, form.submit, value.string_placeholder) and use labelKey / optionKeys in your field definitions so labels and placeholders resolve. See Translations Reference — Form translations for the full example.
Catalogue form layout
The built-in catalogue form opens in a right-side drawer by default. Configure form.layout in your table config when you want the same form to open as a modal instead:
defineTableConfig({
// ...
form: {
layout: {
mode: "modal",
width: "80vw",
},
},
});mode: "drawer" keeps the default behavior. mode: "modal" uses 80vw by default, and width can be any valid CSS width such as "64rem" or "min(80vw, 960px)".
Full props (single entry point)
All props are optional except tableType.
| Prop | Type | Description |
|---|---|---|
tableType | string | Required. Identifier for this table (e.g. "products"). Used to resolve config and actions. |
getTableConfig | (tableType: string) => Config | undefined | Returns columns, table options, default sort, visibility, and translation keys. |
getTableActions | (tableType: string) => TableActions | undefined | Returns list, create, update, delete, duplicate, bulkDelete, bulkCopy, bulkUpdate. |
getFormConfig | (formType: string) => FormConfig | undefined | Returns form field definitions for create/edit and bulk edit. |
translations | DataTableTranslations | Override default UI strings. |
locale | string | Locale for translations (default "en"). |
queryClient | QueryClient | Optional explicit shared client. If provided, it must match your app QueryClientProvider instance. |
title | string | Table title above the toolbar. |
description | string | Short description under the title. |
enableToolbar | boolean | Show toolbar with filters, sort, columns, etc. (default true). |
enableAdvancedFilters | boolean | Enable advanced filters panel (default false). |
columnTypeMapping | Record<string, 'text' | 'number' | 'date' | 'option' | 'multiOption'> | Map backend types to filter/column types. |
onExport | (rows) => void | Promise<void> | Override toolbar export behavior (all filtered rows). |
onBulkExport | (rows) => void | Promise<void> | Override bulk CSV export behavior (selected rows). |
onBulkEdit, onBulkDelete, onBulkCopy | (rows) => BulkActionResult | void | Override bulk behavior; explicit result contract is recommended for deterministic close/selection behavior. |
customBulkActions | BulkAction<TData>[] | ((ctx) => BulkAction<TData>[]) | Add typed custom actions to the selected-row bulk actions menu. |
loadingOverlay | ReactNode | Custom loading UI. |
TitleComponent, DescriptionComponent | ComponentType | Custom title/description components. |
See URL state (Nuqs) for details on URL synchronization.
Common setup mistakes
- Returning a 0-based
pageinlist— YaYaw Table sends and expects a 1-based page number. - Omitting
meta.pageCount/meta.totalCountinlistreturn value. - Using a different
tableTypebetweenDataTable,getTableConfig, andgetTableActions. - Rendering
DataTablein a Server Component while passing function props (getTableConfig,getTableActions). - Forgetting to wrap with a shared
QueryClientProvider(runtime error: missing QueryClient).
Summary
- DataTable is the single component; pass
getTableConfig,getTableActions, and optionalgetFormConfig. - getTableConfig defines table behavior and columns.
- getTableActions connects your server actions or API functions.
- In Next.js App Router, use NuqsAdapter, provide a shared QueryClientProvider, and render
DataTablefrom a Client Component when passing function props.
See also: