Bootcamp
Build a data table in Bubble: Do a search for, filters and search
Photo by Alejandro Barba on Unsplash
Almost every serious app has, at its heart, a table: contacts, orders, jobs, deals. It’s the screen users live in, and in Bubble it’s built from two ideas working together: a repeating group to display a list, and Do a search for to fetch it. Master those two and you’ve mastered the bread and butter of Bubble development, because you’ll use them every single day.
This walkthrough builds a CRM contacts table end to end: the search, the repeating group and row layout, an option set for statuses, then a dropdown filter and free-text search on top.
Do a search for is Bubble’s expression for reading records from the database. You give it a type, optional constraints and a sort order, and it returns a list of matching things, always a list, even when there’s one result or none.
How Do a search for actually works
In any dynamic expression, choose Insert dynamic data → Do a search for. Three decisions follow.
Type is the database entity you’re searching, and it defines the structure of what comes back. Constraints narrow the results, and you can stack several: Created Date > Current date/time +days: -7 would return only records created in the last week. Sort by takes a field and a direction; ascending is the default, and secondary sorts break ties in priority order, much like sorting a spreadsheet by two columns.
Where you apply constraints matters more than beginners expect. A constraint on the search itself is applied server-side, so excluded records are never sent to your browser at all. You can narrow results later with :filtered, but the original search is where narrowing is efficient. Searches that use only what’s described here will be fast; if one takes more than a second or two, come back to these principles.
One more setting worth understanding early: Ignore empty constraints. Normally, a constraint that matches nothing returns nothing: do a search for testimonials where name is Taylor Swift, and since she hasn’t given you a testimonial, you get an empty list. With the box ticked, an empty constraint is simply skipped and everything else comes back. It sounds niche; it turns out to be exactly what a filter dropdown needs, as you’ll see below.
Finally, the concept that catches almost everyone: a search always returns a list. Drop Do a search for Contacts into a text element and the expression goes red, because a text element can’t display a list. Hover any expression and Bubble tells you what it evaluates to. To get a single record out, convert the list: Do a search for Contacts:first item's First Name evaluates to text and works, as does :each item's First Name (a list of texts). If your expressions keep coming back red, list-versus-single-item is nearly always why.
Point a repeating group at the search
The table needs a page: add a Contacts page cloned from your template, ignoring the header, filters and search from the design for now. The table itself is a repeating group. (There’s a beta table element, but repeating groups are the long-standing way Bubble handles lists, and the skills transfer to cards, grids and every other repeated layout.)
A repeating group needs two things, and it’s worth knowing why:
- Type of content:
Contact. This gives the expressions inside each cell a stable structure to refer to, even if the data source changes later. - Data source:
Do a search for Contacts. The actual list. Hover it and confirm it evaluates to a list of contacts, matching the type of content.
Then prove the data is flowing before you build anything visual. Preview the page with the debugger and click the repeating group in the inspector: the panel shows the exact list of contacts being pulled, every field, every setting, without bouncing back to the Data tab. Don’t be scared of the debugger; it’s common to avoid it for months as a beginner, and it’s the single habit that most speeds up how you diagnose problems.
For the genuinely curious there’s a deeper level: in Chrome DevTools’ Network tab, filter by Fetch/XHR, reload, and open the request named msearch. Its response shows exactly which records and fields Bubble sent from server to client, and whether you’re downloading data you don’t need.
Build the table rows
A repeating group works on a template principle: you configure the top cell, and every other cell follows along. Two expressions are available inside a cell: Current cell's Contact (the record, with all its fields) and Current cell's index (its row number).
- Configure the group’s layout: column container, no fixed or minimum width. Each row in the design is 56 pixels tall and there are 11 of them, so set the height to 616 and, on the Appearance tab, a fixed number of 11 rows.
- Add a group in the top cell named Group Row Background: a row container, no minimum width or height, not fit to content, so it fills the whole cell. Set its type of content to
Contactand its data source toCurrent cell's Contact. - Give it the alternating-stripe treatment with a conditional. The trick is modulo, which divides one number by another and returns the remainder, so
index modulo 2alternates 1, 0, 1, 0 down the table. Add the conditionWhen Current cell's index <- modulo -> 2 is 1and set the background colour to your grey 100 (#F0F1F2). Preview: rows one and three grey, two and four plain. - Here’s why setting that group’s type mattered: the top level of the cell refers to
Current cell's..., but everything inside Group Row Background refers toParent group's Contact. Bubble is smart about this; as you group elements, it wires the parent references into your expressions automatically. - Lay out the row’s elements with placeholders first: a 24 by 24 shape for the contact’s colour swatch, a 24 by 24 fixed image for the profile picture, a name text in your label style (wrapping, fit width to content, variable height), then texts for deal count, deal amount and renewal date. Name each element after its on-screen label; the editor should reflect the front end wherever possible.
- For the status column, use Bubble’s native dropdown (102 wide, 32 tall) rather than a styled plugin: it’s fast, needs no configuration, and sticking with native options is often the right call for exactly those reasons.
- Space the columns by wrapping each element in a fixed-width row container: Group Colour at 40, Group Name (picture plus name) at 176, Group Deals at 56, Group Total Value at 120, Group Renewal Date at 140 and Group Status at 180, keeping widths in multiples of four. Match the design’s spacing (8 pixels of left padding on the swatch group, a 16-pixel gap between picture and name, 16 pixels of left padding where columns align left) and centre everything vertically within its group.
- Swap the placeholders for dynamic data. The swatch’s colour is
Parent group's Contact's Colour(it returns the stored hex code); the image isParent group's Contact's Profile Picture; the name isParent group's Contact's First Nameappend[space]appendParent group's Contact's Last Name. Building the name with:appendkeeps each part separately editable later. Deal figures stay as placeholders, since that data doesn’t exist yet. - Two refinements worth adopting: process the profile image with imgix to reduce quality and file size, because a 24-pixel avatar doesn’t need full resolution and huge images are exactly what you don’t want to ship from server to client; and set a canvas placeholder image so the design view doesn’t look broken.
Preview it: real rows, alternating backgrounds, live data. That’s the table.
Option sets: the right home for statuses
The filter is next, and it needs a status on each contact, which introduces one of Bubble’s most useful tools.
Option sets, in the Data tab, are for short, static lists of options that recur across your app: think Draft, Published, Archived. Define the list once and every dropdown, filter and conditional refers to it; rename an option later and the change propagates everywhere, because Bubble stores each option’s ID rather than its label. That’s also why option sets beat free-typed text for statuses. If you’ve ever lost an hour of debugging to a one-character spelling difference between “Archived” in two places, you already understand the value.
The trade-offs to know:
- Only the developer can change option sets, in the editor, and changes take effect only when you deploy live. Database rows can be edited on the fly; option sets can’t.
- They load essentially instantly, where a database search might take a second.
- They can carry their own attributes (a label, an image), and common uses include filters, statuses, user roles, record types and URL parameter values. If an option will appear in URLs, keep its display value lowercase with dashes and add a separate label attribute for humans;
Get data from URLcan then return the option directly, typed, with no database lookup. - They’re the wrong tool for long lists, lists users need to edit, heavily nested data, and anything sensitive: option sets are fully visible to front-end users, with no way to prevent it. A sensible rule of thumb is under about ten simple options, avoiding nesting where possible.
Add the dropdown filter
First, be clear about what a filter is for. Filtering is categorical: it drills into existing categories (an option set or a related data type) to return a subset; free text is a different job, handled by search, below. The type you’re filtering must actually carry the field you filter by, so to show contacts by status, Contact needs a status field. Faking a filter with text comparisons where no real relationship exists is slow and burns workload units.
- Create an option set Contact Status with options Hot, Cold and Archived.
- On the
Contactdata type, add a Status field whose type is the Contact Status option set, with a default of Hot. Note how the default’s choices pull straight from the option set. - Populate the status on your existing rows in App data, so there’s something to filter.
- Configure the “Filter by” dropdown on the Contacts page: choices style Dynamic choices, type of choices
Contact Status, sourceAll Contact Status. (You can compose narrower sources, likeHot:converted to list plus item Cold, but here we want all of them.) Set the option caption toCurrent option's Displayand leave the default value blank. - Add the constraint to the repeating group’s search:
Do a search for ContactswithStatus = Dropdown Filter by's value. - Preview, and the table is suddenly empty. With the dropdown blank, the constraint says “status equals nothing”, which matches no records. Tick Ignore empty constraints on the search and reload: blank now shows everything, and choosing Hot, Cold or Archived drills down correctly.
Add the free-text search
Search complements the filter: it returns records whose text matches whatever the user types, unstructured rather than categorical. Real search engines get very complex under the hood, so the strategy here is deliberately simple and quick.
- Add a text field to
Contactcalled Search Trigger. - Populate it at creation time. In the Create a new contact workflow, set
Search Trigger=Input First Name's value [space] Input Last Name's value [space] Input Email's value. One field now concatenates everything searchable about the record, and you can add more fields whenever you like. Sincecontainsis case sensitive, it’s often wise to store this field as lowercase. - On the Contacts page, make the search input a plain input element; if the template shipped a search box element, right-click and replace the element type.
- Add a second constraint to the repeating group’s search:
Search Trigger contains Input Search's value. - Preview and type something distinctive from one record. One row comes back; clear the input and, thanks to ignore empty constraints again, everything returns.
One refinement to file away: alongside contains there’s contains keywords, a smarter comparison that breaks the query into components, strips common stop words and matches the remaining words, where plain contains just matches the literal string. The Bubble manual covers the difference when you’re deciding which your app needs.
Try it yourself
Build the whole pipeline against your own contacts data: search, repeating group, striped rows, status option set, then the filter and search constraints. Deliberately test the two empty states (dropdown blank, search box empty) and confirm ignore empty constraints gives the behaviour users expect. Then open the debugger, click the repeating group, and read the list it’s actually pulling. If you can explain why every row is there, you understand Bubble’s data layer.
This skill in the AI era
Strip away the Bubble vocabulary and this article is about queries: fetch server-side with constraints, sort deliberately, enumerate statuses once, keep filtering categorical and search textual. Those decisions still make the difference when AI writes the implementation, because Claude Code will happily produce a slow client-side filter if nobody knows to ask for the constrained query. That data instinct is what our Claude Code developers in Sydney bring to every build, whether the table ends up in Bubble or React. In which no-code skills transfer to AI-assisted development, list-handling like this sits near the top.
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.