Skip to main content
RapidDev - Software Development Agency
outsystems-tutorial

Table Sorting, Filtering, and Server-Side Pagination in OutSystems

OutSystems Table Records widgets support server-side pagination via Aggregate properties: set Max Records to your page size (e.g., 20), and use a local StartIndex variable incremented by 20 to load the next page. For column sorting, add a local sort variable, pass it to the Aggregate's Sort, and refresh on column header click. For filtering, add a local search variable and reference it in the Aggregate's Filter condition.

What you'll learn

  • How to configure an Aggregate's Max Records and Start Index for server-side pagination
  • How to build Previous/Next pagination controls connected to a StartIndex local variable
  • How to implement column header sorting by passing a sort expression to the Aggregate
  • How to add a search input that filters Aggregate results in real time via a local filter variable
  • How to combine sorting, filtering, and pagination correctly so they work together
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read35-45 minOutSystems 11 and ODCMarch 2026RapidDev Engineering Team
TL;DR

OutSystems Table Records widgets support server-side pagination via Aggregate properties: set Max Records to your page size (e.g., 20), and use a local StartIndex variable incremented by 20 to load the next page. For column sorting, add a local sort variable, pass it to the Aggregate's Sort, and refresh on column header click. For filtering, add a local search variable and reference it in the Aggregate's Filter condition.

Production-Quality Data Tables in OutSystems

OutSystems generates basic table scaffolding when you drag an Entity onto a screen, but production tables need pagination (to avoid loading thousands of rows), column sorting (to let users find records quickly), and filtering (to narrow results). All three features use the same Aggregate settings — Max Records, Start Index, Sort, and Filters — combined with local variables on the screen. This tutorial builds a complete, fully functional table with all three features working together.

Prerequisites

  • A Reactive Web application with at least one Entity containing 50+ records for testing
  • A screen with a Table widget already scaffolded from an Entity (or follow the beginning steps to create one)
  • Understanding of Aggregates and local variables in OutSystems

Step-by-step guide

1

Scaffold the Table and Configure the Aggregate

Open a screen in the Screen Editor (Interface tab → UI Flows → MainFlow → double-click your screen). If you do not already have a table, drag your Entity from the Data tab onto the screen canvas — OutSystems auto-generates a Table widget with a GetEntities Aggregate and a List widget. For a clean starting point: 1. Double-click the screen's Aggregate (right-click the table on canvas → 'Go to Aggregate', or find GetTasks in the screen's data panel) 2. In the Aggregate editor, open the **Sources** tab — verify your Entity is listed 3. Add all columns you want to display: check that the columns exist in the Output Attributes section 4. Set **Max Records**: In the Aggregate properties (Properties panel with Aggregate selected) → Max Records: `20` Max Records limits the database query to 20 rows per request. This is the core of server-side pagination — only 20 records are fetched per page, not the entire table. Note the Max Records property location: With the Aggregate selected in the action flow (or as a screen aggregate), look for Max Records in the Properties panel. Default is empty (fetches all rows — avoid this for large tables).

Expected result: The Aggregate's Max Records is set to 20. The table shows a maximum of 20 records. Running the app, only 20 records appear in the table even if the database has 100+.

2

Implement Server-Side Pagination with StartIndex

Server-side pagination loads one page at a time. The Aggregate's **Start Index** property tells it which row to start from (0-based). **Add local variables to the screen:** 1. Right-click the screen (Interface tab) → Add Local Variable → Name: `CurrentPage`, Type: Integer, Default: `1` 2. Right-click screen → Add Local Variable → Name: `StartIndex`, Type: Integer, Default: `0` **Bind Start Index on the Aggregate:** With the GetTasks Aggregate selected → Properties panel → **Start Index** → set to: `StartIndex` **Add pagination UI elements below the table:** 1. Drag a Container → Style Classes: `"row justify-content-between align-items-center margin-top-s"` 2. Inside the container: - Drag a Button → Label: `"Previous"` → OnClick → New Client Action: `PreviousPage` - Drag a Text widget → Text expression: `"Page " + IntegerToText(CurrentPage)` - Drag a Button → Label: `"Next"` → OnClick → New Client Action: `NextPage` **PreviousPage Client Action:** Start → If(StartIndex > 0) → [True] Assign: StartIndex = StartIndex - 20, CurrentPage = CurrentPage - 1 → Refresh Data: GetTasks → [False] End **NextPage Client Action:** Start → If(GetTasks.List.Length = 20) → [True] Assign: StartIndex = StartIndex + 20, CurrentPage = CurrentPage + 1 → Refresh Data: GetTasks → [False] End The If(GetTasks.List.Length = 20) check determines whether there are more pages: if the last page returned fewer than 20 records, we are on the last page.

typescript
1/* PreviousPage Client Action:
2 Start
3 If(StartIndex > 0)
4 [True]
5 Assign
6 StartIndex = StartIndex - 20
7 CurrentPage = CurrentPage - 1
8 Refresh Data: GetTasks
9 End
10 [False]
11 End
12
13 NextPage Client Action:
14 Start
15 If(GetTasks.List.Length = 20)
16 [True]
17 Assign
18 StartIndex = StartIndex + 20
19 CurrentPage = CurrentPage + 1
20 Refresh Data: GetTasks
21 End
22 [False]
23 End
24*/

Expected result: The table shows 20 records. Clicking 'Next' loads the next 20 records and increments the page number. Clicking 'Previous' returns to the previous 20. The 'Previous' button on page 1 and 'Next' button on the last page do nothing (guarded by the If checks).

3

Add Column Header Sorting

Column sorting changes the Aggregate's Sort order when users click a column header. **Add local variables:** 1. Right-click screen → Add Local Variable → Name: `SortColumn`, Type: Text, Default: `"Task.Title"` 2. Right-click screen → Add Local Variable → Name: `SortAscending`, Type: Boolean, Default: `True` **Configure Aggregate sorting:** Open the GetTasks Aggregate → **Sorts** tab → Add Sort. However, instead of a static sort, leave the Sorts tab empty and use the **dynamic sort** approach via the `GetTasks.SortColumnList`. Alternatively (simpler for basic sorting), add a Sort in the Sorts tab and set its attribute to a Calculated Attribute or an expression. **Simpler pattern — Refresh with sort variables:** Create a `SortByColumn` Client Action: - Input Parameter: ColumnName (Text) - Flow: - Start - → Assign: SortAscending = If(SortColumn = ColumnName, not SortAscending, True) - → Assign: SortColumn = ColumnName, StartIndex = 0, CurrentPage = 1 - → Refresh Data: GetTasks - → End **In the Aggregate Sorts tab:** - Add Sort: Sort Attribute → Calculated Attribute expression: `If(SortColumn = "Task.Title", Task.Title, Task.DueDate)` with direction based on SortAscending **Wire up column headers:** In the Table widget, select each column header Text widget → Events → OnClick → SortByColumn with the column identifier as input.

typescript
1/* SortByColumn Client Action:
2 Input: ColumnName (Text)
3
4 Start
5 Assign
6 SortAscending = If(SortColumn = ColumnName,
7 not SortAscending,
8 True)
9 SortColumn = ColumnName
10 StartIndex = 0
11 CurrentPage = 1
12 Refresh Data: GetTasks
13 End
14
15 Sort indicator expression for column header:
16 If(SortColumn = "Task.Title",
17 If(SortAscending, " ↑", " ↓"),
18 "")
19*/

Expected result: Clicking a column header changes the sort order. Clicking the same column again toggles between ascending and descending. A visual indicator (↑ or ↓) shows the current sort direction. The table resets to page 1 on sort.

4

Add Live Search Filtering

A search input filters the Aggregate's results dynamically. **Add local variable:** Right-click screen → Add Local Variable → Name: `SearchText`, Type: Text, Default: `""` **Add search Input to the screen:** 1. Drag an Input widget above the table 2. Properties → Variable: `SearchText` 3. Properties → InputType: Text 4. Properties → Placeholder: `"Search tasks..."` 5. Events → OnChange → New Client Action: `OnSearchChange` **OnSearchChange Client Action:** Start → Assign: StartIndex = 0, CurrentPage = 1 → Refresh Data: GetTasks → End (Reset to page 1 when search changes, then refresh) **Add filter to the Aggregate:** Open the GetTasks Aggregate → **Filters** tab → Add Condition: `SearchText = "" or Task.Title like "%" + SearchText + "%"` The `SearchText = ""` part means no filter is applied when the search box is empty (showing all records). When SearchText has a value, only records with matching titles are returned. **Case-insensitive search:** OutSystems' `like` operator is case-sensitive on most databases by default. For case-insensitive search: `SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%"`

typescript
1/* Aggregate Filter condition:
2 SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%"
3
4 OnSearchChange Client Action:
5 Start
6 Assign
7 StartIndex = 0
8 CurrentPage = 1
9 Refresh Data: GetTasks
10 End
11
12 Combined filter with date range example:
13 (SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%")
14 and
15 (FilterStatus = 0 or Task.StatusId = FilterStatus)
16*/

Expected result: Typing in the search box triggers OnSearchChange, which resets the page to 1 and refreshes the Aggregate with the new filter. The table shows only records containing the search text in their Title field.

5

Combine Sorting, Filtering, and Pagination Correctly

When sorting, filtering, and pagination all work together, a key rule applies: **any change to sort or filter must reset StartIndex to 0**. If a user is on page 3 and applies a filter that only returns 10 records (less than one page), staying at StartIndex=40 would show an empty table. **Complete state reset pattern:** Create a `ResetTableState` Client Action: Start → Assign: StartIndex = 0, CurrentPage = 1 → End Call `ResetTableState` at the beginning of: - SortByColumn (before refreshing) - OnSearchChange (before refreshing) - Any status filter change **The OutSystems UI Pagination block (alternative to manual buttons):** If OutSystems UI is referenced in your module, there is a built-in **Pagination** block in the Toolbox: 1. Drag the Pagination block onto the screen below the table 2. Properties: - **TotalCount** → expression: `GetTasksCount.Count` (a separate Aggregate that counts without Max Records) - **PageSize** → `20` - **StartIndex** → `StartIndex` 3. Events → **OnNavigate** → Client Action that updates StartIndex and refreshes GetTasks The Pagination block automatically shows the correct page number, total pages, and handles edge cases.

typescript
1/* Total count aggregate for Pagination block:
2 Aggregate: GetTasksCount
3 - Same sources as GetTasks
4 - Same filters as GetTasks (SearchText filter)
5 - Max Records: empty (count all)
6 - Output: use Count(Task.Id) as aggregate function
7
8 OnNavigate Client Action (for Pagination block):
9 Input: NewStartIndex (Integer)
10 Start
11 Assign: StartIndex = NewStartIndex
12 Refresh Data: GetTasks
13 End
14
15 TotalCount expression on Pagination block:
16 GetTasksCount.List.Current.Count
17*/

Expected result: The table works correctly in all combinations: searching while on page 3 resets to page 1. Sorting while filtered preserves the filter. The Pagination block shows accurate total page count. All three features coexist without conflicts.

Complete working example

table_pagination_local_variables.os
1/* Screen Local Variables:
2 SearchText: Text = ""
3 SortColumn: Text = "Task.Title"
4 SortAscending: Boolean = True
5 StartIndex: Integer = 0
6 CurrentPage: Integer = 1
7
8=== Aggregate: GetTasks ===
9 Source: Task entity
10 Max Records: 20
11 Start Index: StartIndex
12
13 Filters tab:
14 SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%"
15
16 Sorts tab (dynamic):
17 If(SortColumn = "Task.Title",
18 Task.Title,
19 Task.DueDate)
20 Direction: If(SortAscending, Ascending, Descending)
21
22=== Aggregate: GetTasksCount (for Pagination block) ===
23 Source: Task entity
24 Max Records: (empty fetch all for count)
25 Same Filters as GetTasks
26 Output: Count(Task.Id)
27
28=== Client Actions ===
29
30 OnSearchChange:
31 Start Assign(StartIndex=0, CurrentPage=1) Refresh GetTasks Refresh GetTasksCount End
32
33 SortByColumn(ColumnName: Text):
34 Start
35 Assign(SortAscending=If(SortColumn=ColumnName, not SortAscending, True))
36 Assign(SortColumn=ColumnName, StartIndex=0, CurrentPage=1)
37 Refresh GetTasks
38 End
39
40 NextPage:
41 Start If(GetTasks.List.Length=20)
42 [True] Assign(StartIndex+=20, CurrentPage+=1) Refresh GetTasks End
43 [False] End
44
45 PreviousPage:
46 Start If(StartIndex>0)
47 [True] Assign(StartIndex-=20, CurrentPage-=1) Refresh GetTasks End
48 [False] End
49
50=== Sort Indicator Expression (on column header) ===
51 If(SortColumn = "Task.Title",
52 If(SortAscending, " ↑", " ↓"),
53 "")
54
55=== Page Number Display Expression ===
56 "Page " + IntegerToText(CurrentPage) + " of "
57 + IntegerToText(Ceiling(GetTasksCount.List.Current.Count / 20.0))
58*/

Common mistakes

Why it's a problem: Setting Max Records but forgetting to bind Start Index — all pages show the same first 20 records

How to avoid: The Start Index Aggregate property must be bound to your StartIndex local variable. Without this binding, the Aggregate always starts from row 0 regardless of the page. Set Start Index in the Aggregate's Properties panel.

Why it's a problem: Filtering in a Client Action on the full in-memory list instead of adding a filter to the Aggregate

How to avoid: Filtering client-side (in a For Each loop in a Client Action) only filters the already-fetched page. You need to filter at the Aggregate level (Filters tab) so the database query itself is filtered. This ensures correct record counts and pagination.

Why it's a problem: Accessing GetTasks.List.Current outside of a For Each loop or Table widget binding, getting unexpected data

How to avoid: GetTasks.List.Current gives the last accessed record's data, not 'the current record' in a general sense. .Current is only reliable inside a For Each iteration or Table widget row. For column counts or empty state checks, use GetTasks.List.Length or GetTasks.List.Empty.

Why it's a problem: Not resetting StartIndex when the filter or sort changes, resulting in empty pages

How to avoid: Add 'StartIndex = 0' and 'CurrentPage = 1' at the beginning of every Client Action that changes sort or filter criteria. Sort and filter changes must return the user to page 1 to show relevant results.

Best practices

  • Always set Max Records on every Aggregate bound to a Table — unset Max Records fetches all rows and causes performance degradation as data grows
  • Keep a separate count Aggregate (no Max Records, same filters) for accurate pagination display — the main Aggregate cannot report total count and paginated data simultaneously
  • Reset StartIndex to 0 whenever a filter or sort changes — this prevents empty pages when filter results are smaller than the current StartIndex
  • Use the OutSystems UI Pagination block for production tables — it handles edge cases and accessibility better than manual Previous/Next buttons
  • Add database indexes on columns used in sort and filter expressions — OutSystems creates indexes for FK attributes automatically but not for Text columns used in LIKE filters
  • Use ToLower() on both sides of a LIKE filter for case-insensitive search — do not rely on database collation settings being consistent across environments
  • Debounce the OnChange event on search inputs for large datasets — trigger filtering after 300ms of inactivity rather than on every keystroke to reduce server load

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have an OutSystems Reactive Web app with a Task entity. I need to build a table with: (1) server-side pagination showing 20 records per page with Previous/Next buttons and a page counter, (2) column sorting on Title and DueDate that resets to page 1 when sort changes, (3) a live search filter on the Title field that resets to page 1 on each keystroke. Show the local variables needed, the Aggregate filter and sort expressions, and the Client Action flows for each feature.

OutSystems Prompt

In my OutSystems Aggregate GetTasks, I have set Max Records to 20 and Start Index to my StartIndex variable. I also have a SearchText filter. I want to use the OutSystems UI Pagination block instead of manual Previous/Next buttons. What properties do I set on the Pagination block, what count Aggregate do I need, and what should the OnNavigate Client Action do?

Frequently asked questions

How do I get the total record count for display (e.g., 'Showing 1-20 of 150 records')?

Create a second Aggregate (e.g., GetTasksCount) with the same source and filters as your main Aggregate, but with Max Records empty and using a Count() aggregate function on the Id column (add it in the Groups tab: Group By nothing, Count the Id). This returns a single record with the count. Apply the same filter expressions to both Aggregates so the count reflects the filtered total. Refresh both Aggregates in every filter/sort action.

Is there a built-in OutSystems component for pagination, or do I have to build Previous/Next manually?

Yes — the OutSystems UI patterns library includes a Pagination block (under the Navigation category). It handles page number display, First/Last buttons, keyboard navigation, and accessibility attributes. Set its TotalCount, PageSize, and StartIndex properties, and wire its OnNavigate event to a Client Action that updates your StartIndex variable and refreshes the Aggregate. This is faster to set up and more robust than manual Previous/Next buttons.

Why does my table still load all records even after setting Max Records?

Check that Max Records is set on the correct Aggregate. If your screen has multiple Aggregates, ensure you set it on the one bound to the Table widget. Also verify that Start Index is bound to your local variable — without Start Index binding, Max Records limits the fetch but always starts from row 0, which looks correct on page 1 but shows the same first 20 records on every page.

How do I add an 'inline edit' feature to a table row in OutSystems?

Use the Table widget's editable column feature: select a column in the Table → change the cell widget from an Expression to an Input widget → bind it to a record variable. For save logic, add a Save button per row (or a global Save All button) that calls a Server Action with the modified record list. The standard pattern is to maintain an 'edited records' list variable and batch-save with CreateOrUpdate<Entity> in a For Each loop.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.