Docs
Integrations

Server-side & Server Actions

Using Server Actions for list, create, update, delete and bulk operations

Normalize the request contract

The following complete example builds on the quick start and shared recipe files. The reference below explains individual options and integration fragments.

list-records.ts
import {
  compatibleListParams,
  matchesContractFilter,
  normalizeFilterEnvelope,
  recordValue,
} from "@/components/ui/yayaw-table/utils/table-contracts";

export function listRecords<T extends Record<string, unknown>>(
  rows: T[],
  input: unknown
) {
  const params = compatibleListParams(recordValue(input));
  const page = Number(params.page);
  const limit = Number(params.limit);
  const search = String(params.search).toLowerCase();
  const filters = Object.entries(recordValue(params.filters));
  const advanced = normalizeFilterEnvelope(params.advancedFilters).filters;
  const matching = rows.filter((product) => {
    const row: Record<string, unknown> = product;
    const matchesSearch =
      !search ||
      ["name", "reference", "price", "stock", "status", "active", "tags"].some(
        (key) => {
          const value = product[key];
          return (
            value !== null &&
            value !== undefined &&
            String(value).toLowerCase().includes(search)
          );
        }
      );
    const matchesColumns = filters.every(([id, value]) =>
      matchesContractFilter(row[id], {
        type: typeof row[id] === "number" ? "number" : "text",
        operator: Array.isArray(value) ? "isAnyOf" : "contains",
        values: Array.isArray(value) ? value : [value],
      })
    );
    const matchesRule = (rule: Record<string, unknown>) =>
      matchesContractFilter(row[String(rule.columnId)], rule);
    const matchesAdvanced =
      advanced.length === 0 ||
      (params.advancedFilterJoin === "or"
        ? advanced.some(matchesRule)
        : advanced.every(matchesRule));
    return matchesSearch && matchesColumns && matchesAdvanced;
  });
  const sorting = Object.entries(recordValue(params.orderBy));
  matching.sort((left, right) => {
    for (const [id, direction] of sorting) {
      const a = (left as Record<string, unknown>)[id];
      const b = (right as Record<string, unknown>)[id];
      const comparison =
        typeof a === "number" && typeof b === "number"
          ? a - b
          : String(a ?? "").localeCompare(String(b ?? ""));
      if (comparison !== 0) {
        return direction === "desc" ? -comparison : comparison;
      }
    }
    return String(left.id).localeCompare(String(right.id));
  });
  return Promise.resolve({
    // Model a server response: later store mutations must not alter cached rows.
    data: structuredClone(matching.slice((page - 1) * limit, page * limit)),
    meta: {
      pageCount: Math.max(1, Math.ceil(matching.length / limit)),
      totalCount: matching.length,
    },
  });
}
list-records.ts
import {
  compatibleListParams,
  matchesContractFilter,
  normalizeFilterEnvelope,
  recordValue,
} from "@/components/ui/yayaw-table-vue/table-contracts";

export function listRecords<T extends Record<string, unknown>>(
  rows: T[],
  input: unknown
) {
  const params = compatibleListParams(recordValue(input));
  const page = Number(params.page);
  const limit = Number(params.limit);
  const search = String(params.search).toLowerCase();
  const filters = Object.entries(recordValue(params.filters));
  const advanced = normalizeFilterEnvelope(params.advancedFilters).filters;
  const matching = rows.filter((product) => {
    const row: Record<string, unknown> = product;
    const matchesSearch =
      !search ||
      ["name", "reference", "price", "stock", "status", "active", "tags"].some(
        (key) => {
          const value = product[key];
          return (
            value !== null &&
            value !== undefined &&
            String(value).toLowerCase().includes(search)
          );
        }
      );
    const matchesColumns = filters.every(([id, value]) =>
      matchesContractFilter(row[id], {
        type: typeof row[id] === "number" ? "number" : "text",
        operator: Array.isArray(value) ? "isAnyOf" : "contains",
        values: Array.isArray(value) ? value : [value],
      })
    );
    const matchesRule = (rule: Record<string, unknown>) =>
      matchesContractFilter(row[String(rule.columnId)], rule);
    const matchesAdvanced =
      advanced.length === 0 ||
      (params.advancedFilterJoin === "or"
        ? advanced.some(matchesRule)
        : advanced.every(matchesRule));
    return matchesSearch && matchesColumns && matchesAdvanced;
  });
  const sorting = Object.entries(recordValue(params.orderBy));
  matching.sort((left, right) => {
    for (const [id, direction] of sorting) {
      const a = (left as Record<string, unknown>)[id];
      const b = (right as Record<string, unknown>)[id];
      const comparison =
        typeof a === "number" && typeof b === "number"
          ? a - b
          : String(a ?? "").localeCompare(String(b ?? ""));
      if (comparison !== 0) {
        return direction === "desc" ? -comparison : comparison;
      }
    }
    return String(left.id).localeCompare(String(right.id));
  });
  return Promise.resolve({
    // Model a server response: later store mutations must not alter cached rows.
    data: structuredClone(matching.slice((page - 1) * limit, page * limit)),
    meta: {
      pageCount: Math.max(1, Math.ceil(matching.length / limit)),
      totalCount: matching.length,
    },
  });
}

Server-side & Server Actions

The table is designed to work with server-side data: sorting, filtering, and pagination are sent to your backend, and CRUD/bulk operations run on the server. In Next.js, the recommended way to do this is with Server Actions.

How it works

  1. State in the URL – Sort, filters, pagination, and column visibility are stored in the URL (via Nuqs). The client reads these and calls your data layer with the same parameters.

  2. getTableActions(tableType) – You return an object whose methods are your Server Actions (or any async functions that call your API).

  3. list(params) – Called with { filters, advancedFilters, limit, page (1-based), orderBy, search }. Your action runs on the server and returns { data, meta: { pageCount, totalCount } }. Answer a page past the last one (an old link, rows removed since) with the query's meta.totalCount and meta.pageCount and no rows, not an error: since v3.9.1 the table then asks for the last page those counts give (see The page shown).

  4. create, update, delete, duplicate, bulkDelete, bulkCopy, bulkUpdate – Same pattern: the table calls the function you provide; you implement it as a Server Action or API call.

So the library does not fetch data itself; it calls whatever you pass in getTableActions. If that is Server Actions, everything runs on the server.

Next.js Server Actions example

Keep your data logic in a server-only module (e.g. lib/products-server.ts): listing with filter/sort/paginate, create, update, delete, and bulk operations. This file must only be imported from Server Actions or other server code.

// app/example/lib/products-server.ts
import { products as initialProducts } from "../data";

const productsStore = [...initialProducts];

export async function listProducts(params: {
  page?: number;
  limit?: number;
  filters?: Record<string, unknown>;
  advancedFilters?: unknown[];
  orderBy?: Record<string, "asc" | "desc">;
  search?: string;
}) {
  const { page = 1, limit = 10, filters = {}, orderBy = {}, search = "" } = params;
  // Filter, sort, paginate productsStore...
  return { data: pageData, meta: { pageCount, totalCount } };
}

export async function createProduct(data: Record<string, unknown>) {
  // Insert into productsStore or DB
  return { success: true, data: newProduct };
}

export async function updateProduct(id: string, data: Record<string, unknown>) {
  // Update and return
  return { success: true, data: updated };
}

export async function deleteProduct(id: string) {
  return { success: true };
}

export async function bulkDeleteProducts(ids: string[]) { /* ... */ }
export async function bulkCopyProducts(ids: string[]) { /* ... */ }
export async function bulkUpdateProducts(ids: string[], updateData: unknown) { /* ... */ }

2. Server Actions file

Create a file with "use server" that re-exposes these functions (or calls your API). These are what you pass to the table.

// app/example/actions/products.ts
"use server";

import {
  listProducts as listProductsImpl,
  createProduct as createProductImpl,
  updateProduct as updateProductImpl,
  deleteProduct as deleteProductImpl,
  bulkDeleteProducts,
  bulkCopyProducts,
  bulkUpdateProducts,
} from "../lib/products-server";

export async function listProducts(params: Parameters<typeof listProductsImpl>[0]) {
  return await listProductsImpl(params);
}

export async function createProduct(data: Record<string, unknown>) {
  return await createProductImpl(data);
}

export async function updateProduct(id: string, data: Record<string, unknown>) {
  return await updateProductImpl(id, data);
}

export async function deleteProduct(id: string) {
  return await deleteProductImpl(id);
}

export async function bulkDelete(ids: string[]) {
  return await bulkDeleteProducts(ids);
}

export async function bulkCopy(ids: string[]) {
  return await bulkCopyProducts(ids);
}

export async function bulkUpdate(ids: string[], updateData: unknown) {
  return await bulkUpdateProducts(ids, updateData);
}

3. Wire actions to the table

In your table config, return these Server Actions from getTableActions:

// app/example/setup/table-config.ts
import {
  listProducts,
  createProduct,
  updateProduct,
  deleteProduct,
  bulkDelete,
  bulkCopy,
  bulkUpdate,
} from "../actions/products";

export const getTableActions = (tableType: string) => {
  if (tableType === "products") {
    return {
      list: listProducts,
      create: createProduct,
      update: updateProduct,
      delete: deleteProduct,
      bulkDelete: bulkDelete,
      bulkCopy: bulkCopy,
      bulkUpdate: bulkUpdate,
    };
  }
};

bulkDelete, bulkCopy, bulkUpdate without : is also valid JavaScript shorthand when the variable and key have the same name.

When the table needs data or runs an action, it will call these functions. In Next.js they run on the server; params and return values are serialized automatically.

4. Preload the first page on the server

For dashboards and admin pages, prefer a Server Component for the initial read. The client table receives the same first page as initialData, then TanStack Query handles later refreshes, retries, sorting, filtering, and pagination through the same list Server Action.

// app/example/products-page.tsx
import { listProducts } from "./actions/products";
import { ProductsTableClient } from "./products-table-client";

export default async function ProductsPage() {
  const initial = await listProducts({ limit: 10, page: 1 });

  return (
    <ProductsTableClient
      initialData={initial.data}
      initialPageCount={initial.meta.pageCount}
      initialRowCount={initial.meta.totalCount}
    />
  );
}
// app/example/products-table-client.tsx
"use client";

import { DataTable } from "@/components/ui/yayaw-table";
import { listProducts } from "./actions/products";
import { getTableConfig } from "./setup/table-config";

export function ProductsTableClient({
  initialData,
  initialPageCount,
  initialRowCount,
}: {
  initialData: Record<string, unknown>[];
  initialPageCount: number;
  initialRowCount: number;
}) {
  return (
    <DataTable
      getTableActions={() => ({ list: listProducts })}
      getTableConfig={getTableConfig}
      initialData={initialData}
      initialPageCount={initialPageCount}
      initialRowCount={initialRowCount}
      tableType="products"
    />
  );
}

Keep authorization, tenant scoping, and sensitive data filtering in the server code that calls listProducts. The initial* props are serialized rows and counts for the first render; they do not replace the server action used by the table after hydration.

list params shape

The list action receives a single object with:

KeyTypeDescription
pagenumber1-based page index.
limitnumberPage size.
filtersRecord<string, unknown>Column filters (key = column id, value = filter value).
advancedFiltersarrayAdvanced filter rules (columnId, operator, values, type, isActive).
orderByRecord<string, "asc" | "desc">Every sort, in priority order (also sent as the sorting array).
searchstringGlobal search term.

Return:

{ data: T[]; meta?: { pageCount?: number; totalCount?: number } }

Server-side mode (default)

Yayaw Table always runs filtering, pagination, and sorting in server-side mode. No manual* flags are required in table config.

Your list action should handle search, filters, advancedFilters, orderBy, page, and limit.

Example app

The public demo at https://yayaw.app/en/table/example runs in local mode, so edits remain interactive without a backend. Its runtime lives in Yayaw at src/components/ui/catalog/yayaw-table-cms-runtime.tsx.

For a server-backed implementation, provide your own actions through getTableActions using the contracts documented above.

See also:

Shared React/Vue request and result aliases

Both list adapters emit compatible names so existing handlers can keep their original convention:

ValueRequest fields
One-based pagepage
Page sizelimit, pageSize
Ordered sortingorderBy object, sorting array
Searchsearch, q, globalSearch
Column filtersfilters object
Active advanced rulesadvancedFilters array, `advancedFilterJoin: "and""or"`
Window (optional)scope, e.g. { kind: "dateRange", field, endField?, from, to }

Invalid page sizes fall back to defaults. Advanced filter input may be an array or { filters, joinOperator }; inactive rules are omitted and OR remains OR after normalization. Local filtering accepts React select operators (is, isNot, isAnyOf, isNoneOf) and older Vue aliases (equals, notEquals, in, notIn). Local date filtering compares the record's day in the viewer's time zone with the rule's days. Your server must enforce the filters, join, permissions, and sorting itself.

Aggregate calls also carry the join operator. Vue accepts primitive aggregate results as well as React's { raw, label } result shape. Return meta.pageCount or meta.totalCount when the server caps page size, so all-matching export and select-all can collect the complete result.

Date rules

Date rules compare whole days, so their values are calendar days written YYYY-MM-DD: one day, or [first, last] for between, both days included. The same holds for date and timestamp columns. Compare a date field with the day itself; for a timestamp, cover [start of the day, start of the next day) in the time zone you choose (the viewer's, your tenant's or UTC).

{ columnId: "due", type: "date", operator: "between", values: ["2026-09-01", "2026-09-30"], isActive: true }

Every date filter writes days since v3.8.0, in React and Vue: the calendar, the compact chip, the Today, Yesterday, Last 7 days, Last 30 days and This month shortcuts. list, aggregate, exports, URLs and saved views carry days. Older versions wrote the instant of the viewer's local midnight, such as 2026-09-24T22:00:00.000Z for 25 September in Paris, and links and saved views keep those until they are saved again. The browser reads them as the viewer's days. Code that reads saved rules without a browser, such as a scheduled job or an AI tool, passes them through normalizeDateFilterRules(rules, { timeZone }) from utils/date-filter-days.ts, with the zone of the person who saved them. A server that also serves older clients keeps reading an instant as the day it falls on in the viewer's zone.

Windows of rows

Views that need every row of a window rather than one page, such as a period of dates, load it through loadScopedRows and send a scope. A dateRange scope names local calendar days, both inclusive; a row matches when its start date (and optional end date) overlaps them. Filter by the scope on the server and answer meta: { scope: "applied" }. Handlers that ignore scope keep working: the table then filters each page itself, which transfers more data. Results are capped (2,000 rows by default) and report when they were truncated.

Manual order per view

With table.manualOrder, a list request sorted by the __manual sort id also carries viewId (null for the default view). Store one order per view and table, and sort by it. Moves arrive through:

reorder: async ({ viewId, id, previousId, nextId }, context) => {
  // Place `id` between `previousId` and `nextId` in this view's order.
  return { success: true };
}

previousId and nextId are the record's new neighbours; either is absent at an end of the list. Check that the user may edit the view, and return { success: false, error } to reject the move.

Order of views

views.setOrder({ tableId, tableType, viewIds }) receives one person's complete order of their saved views, first to last, without system and default views. Store it as it comes, per user, organization, table type and table id, and answer it back from list as order: the table places system and default views first and views the order does not name last, so your server does not sort. It is a personal preference, like the favorite: check the same access as listing the views, keep only ids of views the person can see if you like, and never let it grant anything. orderViews(views, order) from utils/view-order.ts (server-safe, in both editions) sorts views the table's way when your server needs it. See Order of views.

Tag patches

With tags: { bulk: "patch" } on a tags column, bulk Add tags and Remove tags call bulkUpdate(ids, { [field]: { add, remove } }) once for the whole selection. Apply it to each record's current list with applyTagPatch(current, patch) from utils/tag-catalog.ts (server-safe, in both editions), which removes the tags in remove, appends those of add the list lacks and keeps its order, inside your own authorization and write checks. Without bulk: "patch", bulkUpdate receives each group of rows' resulting lists, which any server can store as they are.