YaYaw TableYaYaw Table

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 shared queryClient instance 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/delete when 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.
  • enableRowClickEdit opens the edit drawer on row click and cannot be combined with urlDisplayMode: "row-link" or inline edit.
  • columnsdefinitions (array of { id, type, header, enableSorting?, enableColumnFilter? }), order, visible, mandatory, sort.
  • translations (optional) – namespace, keys for 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.

  • listRequired for server-driven data. Signature: (params) => Promise<{ data, meta: { pageCount, totalCount } }>. Params include filters, 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.

PropTypeDescription
tableTypestringRequired. Identifier for this table (e.g. "products"). Used to resolve config and actions.
getTableConfig(tableType: string) => Config | undefinedReturns columns, table options, default sort, visibility, and translation keys.
getTableActions(tableType: string) => TableActions | undefinedReturns list, create, update, delete, duplicate, bulkDelete, bulkCopy, bulkUpdate.
getFormConfig(formType: string) => FormConfig | undefinedReturns form field definitions for create/edit and bulk edit.
translationsDataTableTranslationsOverride default UI strings.
localestringLocale for translations (default "en").
queryClientQueryClientOptional explicit shared client. If provided, it must match your app QueryClientProvider instance.
titlestringTable title above the toolbar.
descriptionstringShort description under the title.
enableToolbarbooleanShow toolbar with filters, sort, columns, etc. (default true).
enableAdvancedFiltersbooleanEnable advanced filters panel (default false).
columnTypeMappingRecord<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 | voidOverride bulk behavior; explicit result contract is recommended for deterministic close/selection behavior.
customBulkActionsBulkAction<TData>[] | ((ctx) => BulkAction<TData>[])Add typed custom actions to the selected-row bulk actions menu.
loadingOverlayReactNodeCustom loading UI.
TitleComponent, DescriptionComponentComponentTypeCustom title/description components.

See URL state (Nuqs) for details on URL synchronization.

Common setup mistakes

  • Returning a 0-based page in list — YaYaw Table sends and expects a 1-based page number.
  • Omitting meta.pageCount/meta.totalCount in list return value.
  • Using a different tableType between DataTable, getTableConfig, and getTableActions.
  • Rendering DataTable in 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 optional getFormConfig.
  • 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 DataTable from a Client Component when passing function props.

See also:

On this page