Bootcamp
Build pagination in Bubble from scratch
Photo by Parrish Freeman on Unsplash
Endless scrolling is what you build when you haven’t decided how much data a screen should hold. A table with pagination has made a decision: eleven rows, say, cleanly laid out, with controls that tell users where they are and how much more there is. It’s one of those features users never praise and always notice when it’s missing.
Bubble doesn’t hand you a pagination component, but it hands you every piece you need to build one, and the from-scratch version teaches more about repeating groups than any plugin would.
Pagination splits a long list into fixed-size pages with controls to move between them. Done properly, the current page lives in the URL, so a reload, a shared link or the back button lands users exactly where they were.
The moving parts
This build sits on a CRM contacts table whose repeating group is already configured the right way for paging: layout set to show a fixed number of rows (eleven), with the group’s height sized to match (56-pixel rows times eleven, so 616 pixels). Fixed rows are what make “pages” a real concept the repeating group understands.
On top of that we add four things:
- Back and forward chevron icons.
- A text counter between them (“1 / 10”).
- A
pageURL parameter, written by the chevrons and read on page load. - Bounds handling, so nobody pages past the data.
Step 1: build the controls
- Drop a Material Icon onto the page and check where it landed in the element tree. If it fell inside the table’s wrapper group, pull it out and place it below, as a sibling. The wrapper deliberately has no internal spacing gap, and the 32-pixel rhythm between page sections should stay consistent.
- Size the icon 40 by 40 and set it to the left chevron.
- Copy and paste it for the right chevron.
- Add a text element between them reading
1 / 10as a placeholder. Use the medium regular text style, with height and width both set to fit content so it wraps its text tightly. - Select all three elements, right-click, and Group elements in a row container. Centre-align the row, set its spacing gap to 8 (matching the design’s spacing around the icons), and set the row to fit width to content.
A practical editor tip from the build: while working on elements below a tall table, hide the table’s wrapper group and make sure it has Collapse when hidden ticked. The pagination controls jump up into view and you stop fighting the scroll.
Step 2: put the page in the URL
The core idea: the current page number is state, and state that matters belongs in the URL. There are other ways to wire pagination, but this one means users never lose their position when the page reloads.
Add a workflow: When page is loaded, then under Element actions choose Go to page in a repeating group. Point it at the contacts repeating group and set the page number to Get page from page URL, with the parameter typed as a number. Now ?page=3 in the address bar puts the table on page three the moment the page loads.
Step 3: next and previous
- On the right chevron, add a workflow: when clicked, use Navigation, then Go to page, targeting the contacts page itself.
- Tick Send current page’s parameters. This is what keeps your sort, filters and anything else in the URL intact while the page changes; skip it and every page turn resets the rest of the view.
- Tick Send more parameters to the page and add
page=RepeatingGroup Contacts's page number + 1. - Copy the whole workflow for the left chevron and change the expression to
- 1.
You could compute the new page as Get page from page URL + 1 instead, and it works, but the repeating group’s own page number property is the cleaner read: it’s already a number, and it reflects what the table is actually showing. Preview and click through: one, two, three, four, five, and back down again.
Step 4: stop at the edges
Right now nothing prevents page zero, page minus one, or page fifty of a two-page list. The repeating group exposes exactly the two properties needed to fix it.
- On the right chevron’s Conditional tab, add: when
RepeatingGroup Contacts is on the last page, set the icon colour to grey 300 and set This element isn’t clickable to yes. - Copy the whole conditional onto the left chevron. (Right-click in the margin of the conditional to copy, right-click on the other element’s conditional area to paste; it’s a genuine time-saver once you know it’s there.)
- On the left chevron, change the condition to
is on the first page.
Reload with a single page of data and both chevrons should sit greyed out and dead to clicks, which is exactly correct.
Step 5: the x of y counter
The counter has a numerator and a denominator.
- Numerator: edit the text element, insert a dynamic expression, and use
RepeatingGroup Contacts's page number. That property simply returns the page of results currently showing. - Denominator: use
RepeatingGroup Contacts's List of Contacts:count, which returns the total number of records, then divide by the items per page:/ 11. Click off the field so the 11 registers before you continue the expression. - Append
:ceilingto round the division up to the nearest integer. Ceiling rounds up, floor rounds down, and pagination always rounds up: twelve records at eleven per page is two pages, not one.
With four records that renders as 1 / 1, which is right. To compose the whole thing as one tidy expression rather than fragments of text around dynamic values, use the arbitrary text trick: build arbitrary text :append " / " :append arbitrary text, then edit the first arbitrary text and paste the page-number expression into it, and paste the count-divide-ceiling expression into the last one. You get an identical result with an expression that’s much easier to manage later.
Step 6: test with real volume
Two pages need more than eleven records, so create some. Before generating them, it’s worth patching the create workflow so the new records sort properly alongside pagination: if your table sorts on a dedicated field like full name trigger rather than the raw first and last names, edit the save workflow to populate it as the record is created, with Input First Name's value, a space, then Input Last Name's value. (Getting the literal space to stick between two dynamic expressions is fiddly; click carefully.) Later that job moves to database triggers, but setting it on create keeps the sort honest for now.
Then a quick route to volume: if your create-contact form doesn’t reset itself after saving, you can mash the save button and mint a batch of test records in seconds. Thirteen records gives eleven on page one and two on page two, and the counter should follow along: 1 / 2, then 2 / 2 with the right chevron greyed out.
One loose end worth knowing about: if someone lands on the page with no page parameter at all, parts of this setup have nothing to read. The fix is a small page-load redirect that fills in page=1 whenever the parameter is empty, which we cover alongside the update workflows in this module.
Try it yourself
Rebuild the pattern on your own data type, then put it through its paces: refresh mid-list and confirm you stay on the same page, click into both bounds, and share a URL like ?page=2&sort=name with yourself in another tab to watch the whole view reconstruct. Notice, too, that the number eleven now lives in two places (the repeating group’s row count and the counter’s division). That’s the kind of detail you write down, because whoever changes one without the other introduces the app’s most confusing off-by-one bug.
This skill in the AI era
Page state in the URL, a parameterised query, count divided by page size rounded up, controls disabled at the bounds: this is the pagination spec for every stack, and any framework Claude Code writes in will implement it in minutes once you can state it. What the tools still don’t decide for you is the design: what state survives a refresh, what a shared link should restore, where the page size is allowed to live. Builders who learned that by wiring it manually are the ones who made the jump from Bubble to AI-assisted development fastest, and it’s the thinking we bring when we build software with AI for Sydney businesses.
If this sparked something, let's talk.
No pitch, no pressure — just a conversation about what you're working on.
Let's talkRelated posts
Bootcamp
A simple approach to data modelling
A four-step method for designing a database schema: identify entities, map relationships, choose fields. Taught through the CRM data model built in Bubble.
Bootcamp
Module 5 — Application architecture: pages, states and reusable pieces
Single-page vs multi-page apps, URL parameters, custom states, reusable elements — and a big practical building a main template with an expanding navigation drawer.
Bootcamp
API calls as actions vs data (and the planning checklist)
When to use Bubble API calls as actions versus data sources, a one-to-many auth pattern, and the planning checklist that keeps integrations from failing silently.