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
Scaffold the Table and Configure the Aggregate
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+.
Implement Server-Side Pagination with StartIndex
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.
1/* PreviousPage Client Action:2 Start3 → If(StartIndex > 0)4 [True]5 → Assign6 StartIndex = StartIndex - 207 CurrentPage = CurrentPage - 18 → Refresh Data: GetTasks9 → End10 [False]11 → End1213 NextPage Client Action:14 Start15 → If(GetTasks.List.Length = 20)16 [True]17 → Assign18 StartIndex = StartIndex + 2019 CurrentPage = CurrentPage + 120 → Refresh Data: GetTasks21 → End22 [False]23 → End24*/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).
Add Column Header Sorting
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.
1/* SortByColumn Client Action:2 Input: ColumnName (Text)34 Start5 → Assign6 SortAscending = If(SortColumn = ColumnName,7 not SortAscending,8 True)9 SortColumn = ColumnName10 StartIndex = 011 CurrentPage = 112 → Refresh Data: GetTasks13 → End1415 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.
Add Live Search Filtering
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) + "%"`
1/* Aggregate Filter condition:2 SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%"34 OnSearchChange Client Action:5 Start6 → Assign7 StartIndex = 08 CurrentPage = 19 → Refresh Data: GetTasks10 → End1112 Combined filter with date range example:13 (SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%")14 and15 (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.
Combine Sorting, Filtering, and Pagination Correctly
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.
1/* Total count aggregate for Pagination block:2 Aggregate: GetTasksCount3 - Same sources as GetTasks4 - Same filters as GetTasks (SearchText filter)5 - Max Records: empty (count all)6 - Output: use Count(Task.Id) as aggregate function78 OnNavigate Client Action (for Pagination block):9 Input: NewStartIndex (Integer)10 Start11 → Assign: StartIndex = NewStartIndex12 → Refresh Data: GetTasks13 → End1415 TotalCount expression on Pagination block:16 GetTasksCount.List.Current.Count17*/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
1/* Screen Local Variables:2 SearchText: Text = ""3 SortColumn: Text = "Task.Title"4 SortAscending: Boolean = True5 StartIndex: Integer = 06 CurrentPage: Integer = 178=== Aggregate: GetTasks ===9 Source: Task entity10 Max Records: 2011 Start Index: StartIndex1213 Filters tab:14 SearchText = "" or ToLower(Task.Title) like "%" + ToLower(SearchText) + "%"1516 Sorts tab (dynamic):17 If(SortColumn = "Task.Title",18 Task.Title,19 Task.DueDate)20 Direction: If(SortAscending, Ascending, Descending)2122=== Aggregate: GetTasksCount (for Pagination block) ===23 Source: Task entity24 Max Records: (empty — fetch all for count)25 Same Filters as GetTasks26 Output: Count(Task.Id)2728=== Client Actions ===2930 OnSearchChange:31 Start → Assign(StartIndex=0, CurrentPage=1) → Refresh GetTasks → Refresh GetTasksCount → End3233 SortByColumn(ColumnName: Text):34 Start35 → Assign(SortAscending=If(SortColumn=ColumnName, not SortAscending, True))36 → Assign(SortColumn=ColumnName, StartIndex=0, CurrentPage=1)37 → Refresh GetTasks38 → End3940 NextPage:41 Start → If(GetTasks.List.Length=20)42 [True] → Assign(StartIndex+=20, CurrentPage+=1) → Refresh GetTasks → End43 [False] → End4445 PreviousPage:46 Start → If(StartIndex>0)47 [True] → Assign(StartIndex-=20, CurrentPage-=1) → Refresh GetTasks → End48 [False] → End4950=== Sort Indicator Expression (on column header) ===51 If(SortColumn = "Task.Title",52 If(SortAscending, " ↑", " ↓"),53 "")5455=== 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation