Pagination
The Table Widget supports two pagination strategies in Data Mode. Both work with relational databases (MySQL, PostgreSQL, etc.) and NoSQL databases (DynamoDB, MongoDB, etc.).
Strategy 1 — Pagination Without Lazy Loading
Use this when your dataset is small enough to return all records in a single backend call.
How it works:
- Enable Pagination in the widget properties
- Leave Lazy Loading disabled
- The backend returns all records in a single response
- The widget handles client-side pagination display
Required response shape:
The backend response must include a tableMeta key with rowCount:
{
"widgetData": [ ... ],
"tableMeta": {
"rowCount": 100
}
}
- C# / .NET
- PHP
var tableData = new List<Dictionary<string, string>>();
int totalCount = 100;
for (int i = 1; i <= totalCount; i++)
{
tableData.Add(new Dictionary<string, string>
{
{ "_id", i.ToString() },
{ "_origformcode", "" },
{ "_code", "" },
{ "_plugincode", "" },
{ "desc", "Test item" },
{ "qty", "1" },
{ "unit", "ea" },
{ "price", "100" }
});
}
response.Set(HandlerResponse.WidgetData, tableData);
var meta = _pluginContextProvider.GetDataTableMeta();
meta["rowCount"] = totalCount;
response.Set(HandlerResponse.TableMeta, meta);
<?php
$totalCount = 100;
$tableData = [];
for ($i = 1; $i <= $totalCount; $i++) {
$tableData[] = [
"_id" => (string)$i,
"_origformcode" => "",
"_code" => "",
"_plugincode" => "",
"desc" => "Test item",
"qty" => "1",
"unit" => "ea",
"price" => "100"
];
}
$response = [
"widgetData" => $tableData,
"tableMeta" => [
"rowCount" => $totalCount
]
];
echo json_encode($response);
Strategy 2 — Pagination With Lazy Loading
Use this for large datasets where returning all records at once is impractical or expensive.
How it works:
- Enable both Pagination and Lazy Loading in the widget properties
- Each time the user navigates to a new page, the widget sends an
onTableLoadDataevent to the backend - The backend returns only the requested page of records
- A continuation token (
nextToken) tracks position across requests
Required response shape:
{
"widgetData": [ ... ],
"tableMeta": {
"rowCount": 500,
"lastRequestLimit": 20,
"nextToken": "<opaque-token>",
"qId": "<unique-query-id>"
}
}
| Field | Description |
|---|---|
rowCount | Total number of records. Use -1 if the total is unknown or expensive to compute (e.g., DynamoDB). |
lastRequestLimit | The rowsPerPage value from the request, echoed back in the response. |
nextToken | An opaque token your backend calculates to represent the position of the next page. The widget sends this token back on the next request. |
qId | A unique identifier for this query. Use Guid.NewGuid().ToString() or uniqid(). |
Incoming request metadata (request.DataTableMeta):
| Field | Description |
|---|---|
rowsPerPage | How many rows the widget is requesting. |
nextToken | The token returned by the previous response (empty string on first load). |
Backend Examples
- C# / .NET
- PHP
Amazon DynamoDB
DynamoDB uses its own LastEvaluatedKey as the continuation mechanism. Serialize it to JSON to use as nextToken.
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using System.Text.Json;
public async Task<IActionResult> RunEvent([FromBody] PluginContextEventRequestDto request)
{
var client = new AmazonDynamoDBClient();
int limit = request.DataTableMeta.rowsPerPage;
Dictionary<string, AttributeValue>? exclusiveStartKey = null;
// Deserialize the incoming nextToken back to a DynamoDB key
if (!string.IsNullOrEmpty(request.DataTableMeta.nextToken))
{
exclusiveStartKey = JsonSerializer.Deserialize<Dictionary<string, AttributeValue>>(
request.DataTableMeta.nextToken
);
}
var queryRequest = new QueryRequest
{
TableName = "Orders",
KeyConditionExpression = "pk = :pk",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":pk"] = new AttributeValue { S = "ORDER" }
},
Limit = limit,
ExclusiveStartKey = exclusiveStartKey
};
var result = await client.QueryAsync(queryRequest);
// Map DynamoDB items to table rows
var tableData = new List<Dictionary<string, string>>();
foreach (var item in result.Items)
{
tableData.Add(new Dictionary<string, string>
{
["_id"] = item["id"].S,
["desc"] = item["desc"].S,
["qty"] = item["qty"].N,
["unit"] = item["unit"].S,
["price"] = item["price"].N
});
}
response.Set(HandlerResponse.WidgetData, tableData);
// Serialize LastEvaluatedKey as the nextToken for the widget
string? nextToken = null;
if (result.LastEvaluatedKey != null && result.LastEvaluatedKey.Count > 0)
{
nextToken = JsonSerializer.Serialize(result.LastEvaluatedKey);
}
var meta = _pluginContextProvider.GetDataTableMeta();
meta["rowCount"] = -1; // Total count is expensive in DynamoDB
meta["lastRequestLimit"] = limit;
meta["nextToken"] = nextToken;
meta["qId"] = Guid.NewGuid().ToString();
response.Set(HandlerResponse.TableMeta, meta);
return Ok(response);
}
MySQL
MySQL supports OFFSET-based pagination. The nextToken carries the current offset between requests.
public async Task<IActionResult> RunEvent([FromBody] PluginContextEventRequestDto request)
{
int limit = request.DataTableMeta.rowsPerPage;
int offset = 0;
if (!string.IsNullOrEmpty(request.DataTableMeta.nextToken))
offset = int.Parse(request.DataTableMeta.nextToken);
using var conn = new MySqlConnection(connectionString);
await conn.OpenAsync();
// Fetch the requested page
var cmd = new MySqlCommand(@"
SELECT id, descr, qty, unit, price
FROM orders
ORDER BY id
LIMIT @limit OFFSET @offset", conn);
cmd.Parameters.AddWithValue("@limit", limit);
cmd.Parameters.AddWithValue("@offset", offset);
var reader = await cmd.ExecuteReaderAsync();
var tableData = new List<Dictionary<string, string>>();
while (await reader.ReadAsync())
{
tableData.Add(new Dictionary<string, string>
{
["_id"] = reader["id"].ToString(),
["desc"] = reader["descr"].ToString(),
["qty"] = reader["qty"].ToString(),
["unit"] = reader["unit"].ToString(),
["price"] = reader["price"].ToString()
});
}
reader.Close();
// Total count is inexpensive in MySQL
var countCmd = new MySqlCommand("SELECT COUNT(*) FROM orders", conn);
int totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
response.Set(HandlerResponse.WidgetData, tableData);
var meta = _pluginContextProvider.GetDataTableMeta();
meta["rowCount"] = totalCount;
meta["lastRequestLimit"] = limit;
meta["nextToken"] = (offset + limit).ToString(); // next offset
meta["qId"] = Guid.NewGuid().ToString();
response.Set(HandlerResponse.TableMeta, meta);
return Ok(response);
}
MySQL (Keyset Pagination)
This example uses keyset pagination (cursor-based), which avoids the performance issues of large OFFSETs.
<?php
header('Content-Type: application/json');
$pdo = new PDO(
"mysql:host=localhost;dbname=app;charset=utf8mb4",
"user",
"password",
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
$limit = isset($_GET['rowsPerPage']) ? (int)$_GET['rowsPerPage'] : 20;
$nextToken = $_GET['nextToken'] ?? null;
$lastId = $nextToken ? (int)$nextToken : 0;
// Keyset pagination — fetch rows after the last seen id
$sql = "
SELECT id, descr, qty, unit, price
FROM orders
WHERE id > :lastId
ORDER BY id
LIMIT :limit
";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$tableData = [];
$newLastId = $lastId;
foreach ($rows as $row) {
$newLastId = $row['id'];
$tableData[] = [
"_id" => (string)$row['id'],
"desc" => $row['descr'],
"qty" => (string)$row['qty'],
"unit" => $row['unit'],
"price" => (string)$row['price']
];
}
$response = [
"widgetData" => $tableData,
"tableMeta" => [
"rowCount" => -1, // Run COUNT(*) here if needed
"lastRequestLimit" => $limit,
"nextToken" => count($tableData) ? (string)$newLastId : null,
"qId" => uniqid()
]
];
echo json_encode($response);
Choosing a Pagination Strategy
| Scenario | Recommendation |
|---|---|
| Small dataset (< a few hundred rows) | Pagination only, return all records |
| Large dataset, relational DB | Pagination + Lazy Loading, use OFFSET or keyset |
| Large dataset, DynamoDB | Pagination + Lazy Loading, use LastEvaluatedKey as token |
| Total count is unknown or expensive | Set rowCount: -1 |