Enable pagination in the Retool Table Inspector under Interaction → Pagination. For client-side pagination, just toggle it on. For server-side pagination, use table1.page and table1.pageSize in a SQL LIMIT/OFFSET query with 'Run when inputs change' enabled. Always show the total count with a separate COUNT query for proper navigation.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 15-20 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Client-Side vs Server-Side Pagination in Retool
Retool tables support two pagination modes. Client-side pagination loads all data from the query at once and divides it into pages in the browser. This is simple to set up and works well for datasets up to a few thousand rows. Server-side pagination fetches only one page of data at a time from the database, which is necessary for large tables (10,000+ rows) where loading everything at once would be slow and memory-intensive.
This tutorial covers both approaches, the table1.page and table1.pageSize properties you use for server-side implementation, how to display total row counts, and how to handle edge cases like jumping to a specific page.
Prerequisites
- A Retool app with a Table component
- For server-side pagination: a SQL database resource
- Basic familiarity with SQL LIMIT/OFFSET syntax
Step-by-step guide
Enable Client-Side Pagination
Select your Table component. In the Inspector, go to the Interaction section and find 'Pagination type'. Set it to 'Client-side' (or 'Pagination' depending on your Retool version). Configure the page size — common values are 10, 25, or 50 rows per page. Client-side pagination loads all query results into the browser and divides them into pages. No changes to your SQL query are needed. This works well for tables with fewer than a few thousand rows.
Expected result: Pagination navigation appears at the bottom of the table with Previous/Next buttons and page numbers.
Switch to Server-Side Pagination Mode
For large datasets, switch to server-side pagination. In the Table Inspector, set 'Pagination type' to 'Server-side'. This tells Retool the table is paginated server-side — Retool will no longer try to paginate client-side data. With server-side pagination enabled, you must update your SQL query to use LIMIT and OFFSET based on the current page. The table will show the navigation controls but relies on your query to return the correct page of data.
Expected result: The table shows pagination controls but now expects your query to return only the current page's data.
Add LIMIT/OFFSET to Your SQL Query
With server-side pagination enabled, update your SQL query to use table1.page and table1.pageSize. The page property is 1-based (first page = 1), so the OFFSET formula is (page - 1) * pageSize. Enable 'Run when inputs change' in the query's Advanced settings so it re-runs when the user navigates to a different page.
1-- PostgreSQL example with server-side pagination:2SELECT3 id,4 first_name,5 last_name,6 email,7 status,8 created_at9FROM users10WHERE11 ({{ searchInput.value || '' }} = ''12 OR first_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}13 OR last_name ILIKE {{ '%' + (searchInput.value || '') + '%' }})14ORDER BY created_at DESC15LIMIT {{ table1.pageSize || 25 }}16OFFSET {{ ((table1.page || 1) - 1) * (table1.pageSize || 25) }};1718-- MySQL / SQLite equivalent:19-- LIMIT 25 OFFSET 0 (page 1)20-- LIMIT 25 OFFSET 25 (page 2)21-- LIMIT 25 OFFSET 50 (page 3)Expected result: The query fetches only 25 rows per page, dramatically reducing initial load time for large tables.
Get the Total Row Count for Pagination Display
Server-side pagination needs a total count to display 'Page 2 of 47' style navigation. Create a second SQL query named 'getUsersCount' that returns the total number of matching rows. Apply the same WHERE filters as your main query but use COUNT(*) instead of SELECT. In the Table Inspector, set the 'Total rows' property to {{ getUsersCount.data[0].count }} so the table knows how many pages to show in the navigation.
1-- SQL Query: getUsersCount2-- Must use same WHERE filters as main data query3SELECT COUNT(*) AS count4FROM users5WHERE6 ({{ searchInput.value || '' }} = ''7 OR first_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}8 OR last_name ILIKE {{ '%' + (searchInput.value || '') + '%' }});910-- In Table Inspector → Interaction → Total rows:11-- {{ parseInt(getUsersCount.data[0].count) || 0 }}Expected result: The table pagination shows accurate page counts like 'Page 2 of 47' instead of just Previous/Next buttons.
Configure Page Size Controls
Add a Select component to let users control how many rows appear per page. Name it 'pageSizeSelect' and configure static options: [{label: '10', value: 10}, {label: '25', value: 25}, {label: '50', value: 50}, {label: '100', value: 100}]. If your table supports dynamic page size, bind the Table Inspector's 'Page size' to {{ pageSizeSelect.value }} instead of a hardcoded number. The SQL query already uses {{ table1.pageSize }} so it will adapt automatically.
1// pageSizeSelect options (static):2// [{ label: '10 per page', value: 10 },3// { label: '25 per page', value: 25 },4// { label: '50 per page', value: 50 },5// { label: '100 per page', value: 100 }]67// Table Inspector → Interaction → Page size:8// {{ pageSizeSelect.value || 25 }}910// When page size changes, reset to page 1 to avoid empty pages:11// pageSizeSelect event handler → Run JS Query:12await table1.setCurrentPage(1);13// Note: table1.setCurrentPage() is asyncExpected result: Users can change the number of rows per page and the table re-queries accordingly.
Handle Pagination Reset When Filters Change
When a user applies a filter, the total number of rows changes and the current page may no longer be valid. Reset to page 1 whenever a filter changes. Add a JS Query that resets the page and re-runs the count query, triggered from filter component event handlers.
1// JS Query: resetPaginationOnFilterChange2// Triggered by searchInput onChange and statusFilter onChange34// Reset table to page 1:5await table1.setCurrentPage(1);67// Retool will automatically re-run queries with 'Run when inputs change'8// since table1.page just changed to 1 (from whatever it was)910// If count query doesn't auto-run, trigger it:11await getUsersCount.trigger();1213// Event handler setup:14// searchInput → Change → Run JS Query: resetPaginationOnFilterChangeExpected result: Applying a filter always returns the user to page 1 with the correct total count.
Show Loading State During Page Transitions
Add a loading indicator so users see feedback during server-side page transitions. Use query1.isFetching in a component's 'Disabled' or 'Loading' property. You can also show a spinner using a Spinner component with its 'Visible' property bound to {{ query1.isFetching }}.
1// Spinner component visibility:2{{ query1.isFetching }}34// Disable 'Next Page' button while loading:5{{ query1.isFetching }}67// Show row count info:8{{ !query1.isFetching9 ? 'Showing ' + ((table1.page - 1) * table1.pageSize + 1) +10 '-' + Math.min(table1.page * table1.pageSize,11 parseInt(getUsersCount.data[0]?.count || 0)) +12 ' of ' + (getUsersCount.data[0]?.count || '?')13 : 'Loading...'14}}Expected result: Users see a loading indicator during page transitions and a clear 'Showing X-Y of Z' row count.
Complete working example
1-- Query 1: getUsersPaginated2-- Enable: 'Run when inputs change'3SELECT4 id,5 first_name,6 last_name,7 email,8 department,9 status,10 revenue,11 created_at12FROM users13WHERE14 (15 {{ searchInput.value || '' }} = ''16 OR first_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}17 OR last_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}18 OR email ILIKE {{ '%' + (searchInput.value || '') + '%' }}19 )20 AND (21 {{ statusFilter.value || '' }} = ''22 OR status = {{ statusFilter.value }}23 )24ORDER BY25 created_at DESC26LIMIT {{ table1.pageSize || 25 }}27OFFSET {{ ((table1.page || 1) - 1) * (table1.pageSize || 25) }};2829-- Query 2: getUsersCount30-- Enable: 'Run when inputs change'31SELECT COUNT(*) AS count32FROM users33WHERE34 (35 {{ searchInput.value || '' }} = ''36 OR first_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}37 OR last_name ILIKE {{ '%' + (searchInput.value || '') + '%' }}38 OR email ILIKE {{ '%' + (searchInput.value || '') + '%' }}39 )40 AND (41 {{ statusFilter.value || '' }} = ''42 OR status = {{ statusFilter.value }}43 );4445-- Table Inspector settings:46-- Pagination type: Server-side47-- Page size: {{ pageSizeSelect.value || 25 }}48-- Total rows: {{ parseInt(getUsersCount.data[0]?.count) || 0 }}Common mistakes
Why it's a problem: Using client-side pagination on a table with 50,000 rows, causing slow loads
How to avoid: Switch to server-side pagination in the Table Inspector and add LIMIT/OFFSET to your SQL query referencing table1.page and table1.pageSize. Load only the current page of data.
Why it's a problem: Server-side pagination shows '?' for total pages because Total rows is not configured
How to avoid: Create a separate COUNT(*) query with the same WHERE conditions and bind its result to the Table Inspector's 'Total rows' field: {{ parseInt(countQuery.data[0].count) || 0 }}.
Why it's a problem: Not resetting to page 1 when filters change, leaving the user on an empty page
How to avoid: Add an event handler on filter components that calls await table1.setCurrentPage(1) whenever a filter changes. This ensures the user always sees the first page of filtered results.
Why it's a problem: Forgetting that table1.page is 1-based, calculating OFFSET as table1.page * pageSize
How to avoid: The correct OFFSET formula is (table1.page - 1) * table1.pageSize. Using table1.page * pageSize skips the first page of results.
Best practices
- Use server-side pagination for any table with more than 5,000 rows — loading all rows client-side causes slow initial loads and memory issues.
- Always pair server-side pagination with a COUNT query using the same WHERE filters to show accurate total page counts.
- Reset to page 1 whenever filters change — staying on page 5 when the filtered result has only 2 pages causes an empty table.
- Use fallback values in LIMIT/OFFSET expressions: {{ table1.pageSize || 25 }} and {{ (table1.page || 1) }} to prevent SQL errors on initial page load.
- Enable 'Run when inputs change' on pagination queries — without it, navigating pages doesn't trigger a re-query.
- For PostgreSQL, wrap COUNT(*) results in parseInt() when binding to 'Total rows' since COUNT returns a string.
- Consider prefetching the next page in a background JS Query for smoother UX on large datasets.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool app with a Table component (table1) showing users from PostgreSQL. The table has 500,000 rows and I need server-side pagination. Write two SQL queries: (1) 'getUsersPaginated' that uses table1.page and table1.pageSize in LIMIT/OFFSET with optional text search (searchInput.value) filtering by first_name and email; (2) 'getUsersCount' that returns the total matching row count with the same WHERE clause. Show the Retool Table Inspector settings I need to configure for server-side pagination and total rows display.
Set up server-side pagination for my Retool table1. Update my existing SQL query to use LIMIT {{ table1.pageSize || 25 }} OFFSET {{ ((table1.page || 1) - 1) * (table1.pageSize || 25) }}. Create a separate COUNT query named getUsersCount. Configure the Table Inspector with pagination type server-side, page size 25, and total rows from the count query. Also add a page size Select component with options 10/25/50/100 and reset-to-page-1 logic when filters change.
Frequently asked questions
What is the difference between client-side and server-side pagination in Retool?
Client-side pagination loads all data into the browser at once and splits it into pages in JavaScript — simple to set up but impractical for large datasets. Server-side pagination fetches only one page from the database at a time using LIMIT/OFFSET, which is efficient for large tables. Use client-side for up to ~5,000 rows; use server-side for anything larger.
Why does my Retool table show 'Page 1 of ?' with server-side pagination?
The table needs a total row count to compute the number of pages. Create a separate SQL query with COUNT(*) (using the same WHERE filters as your data query) and bind its result to the Table Inspector's 'Total rows' field: {{ parseInt(countQuery.data[0].count) || 0 }}. PostgreSQL returns COUNT as a string, so parseInt() is needed.
How do I access the current page number in a Retool SQL query?
Use {{ table1.page }} for the current page number (1-based) and {{ table1.pageSize }} for the rows per page. The OFFSET formula is {{ ((table1.page || 1) - 1) * (table1.pageSize || 25) }}. The fallback values (|| 1 and || 25) handle the initial state before the table renders.
How do I programmatically navigate to a specific page in a Retool table?
Use await table1.setCurrentPage(pageNumber) in a JS Query. This method is async, so use await before reading table1.page if you need the updated value. For example, to jump to page 1 after a filter change: await table1.setCurrentPage(1).
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation