---
title: "Data and reports"
url: https://flexie.io/resources/customer-portals/data-and-reports
description: "Where a portal's numbers come from, and the one rule that decides whether your portal is correct or a leak. The report has to narrow itself to the signed-in customer."
---

# Data and reports

Last updated 14 August 2026

![Two versions of the same report, one without a customer condition returning every row, one narrowed with entity.id returning only the signed-in customer's rows](https://flexie.io/image/resources/customer-portals-data-and-reports.webp)

The data grid, the metric, the chart, the calendar and the map are all fed the same way: by a **saved report**. Not by an entity, not by a filter you configure on the component, and not by anything the browser asks for.

That is deliberate. A report already counts, sums, joins, filters and formats, and it is a thing you can open, run and check on its own. A second, weaker query builder inside each component would be one more place to be wrong.

The full guide to reports themselves is [Reports](https://flexie.io/resources/reports/overview).

## The rule: the report narrows itself to the customer

> The portal adds **nothing** to your report's SQL. If the report does not narrow itself to the signed-in customer, every customer sees every row.

The signed-in customer is available to the report as `entity`, so the condition goes where every other condition goes:

```sql
SELECT number, issued_on, total, status
FROM   invoices
WHERE  contact_id = {{ entity.id }}
ORDER  BY issued_on DESC
```

| You have                                  | You write                                           |
| ----------------------------------------- | --------------------------------------------------- |
| a column holding the customer's id        | WHERE contact\_id = {{ entity.id }}                 |
| a join to reach them                      | join it, then compare against {{ entity.id }}       |
| an account portal, rows tied to a company | WHERE account\_id = {{ entity.id }}                 |
| anything on the record itself             | {{ entity.email }}, {{ entity.company }}, any field |

Nothing else in the product will raise this. A report with no such condition runs perfectly, returns rows, and draws a healthy-looking table full of other people's data. **Check every report before you put it on a portal**, and check it again after anybody edits it.

The safest habit is to write the condition first, before the columns, and to open the report as a test customer once the component is on the page.

## Data Grid reports, not HTML reports

Reports come in two output formats, and portal components want the first:

| Report format | Use on a portal                                     |
| ------------- | --------------------------------------------------- |
| **Data Grid** | the grid, metric, chart, calendar and map: all five |
| **HTML**      | not offered to those components at all              |

An HTML report renders its own markup and returns no columns, so a chart pointed at one could only ever draw an empty picture. The pickers only offer Data Grid reports for that reason. If you want an HTML report's kind of freedom on a portal, that is what the [HTML component](https://flexie.io/resources/customer-portals/html-components) is for, and it is better at it: it is edited where you are standing rather than in another screen.

## Choosing the columns

Once a component names a report, every column picker on it offers **that report's own columns**. There is no naming convention to obey and nothing to rename: a map does not need columns called `lat` and `lng`, it needs you to say which of the report's columns those are.

Two consequences:

* **Answer the report setting first.** The column pickers have nothing to offer until they know which report to read.
* **Renaming a column in the report can empty a setting** that pointed at it. If a component stops drawing after somebody edited a report, that is the first thing to check.

## What a customer's browser may ask for

Components are interactive: a grid pages and sorts, a calendar moves month, and your own markup can refresh a component with different parameters. So it is worth stating what a browser can and cannot influence:

| Component       | May adjust                                             |
| --------------- | ------------------------------------------------------ |
| Data grid       | rows per page, which page, sort column, sort direction |
| Chart           | the row limit                                          |
| Map             | the marker limit                                       |
| Calendar        | the row limit, and the visible date range              |
| Everything else | nothing                                                |

Every one of those either narrows the answer, pages within it, or reorders it. **None of them can widen what the report returned**, and the limits are capped on the server, so nobody can turn a paged table into a full export by asking for a big enough page.

## The ceilings

| Ceiling            | Limit |
| ------------------ | ----- |
| Rows per request   | 200   |
| Chart rows plotted | 500   |
| Calendar events    | 500   |
| Map markers        | 200   |

These are guards, not settings: something has to stand between a report that returns a hundred thousand rows and a page trying to draw them. A component regularly hitting one of them is a sign the report should be narrower, usually by date.

For a calendar in particular, the answer is the visible range:

```sql
WHERE appointment_date BETWEEN '{{ range.from }}' AND '{{ range.to }}'
```

Written that way, the report returns the month the customer is looking at, and answers again each time they move.

## Reports written for staff, reused on a portal

Most reports start life on a dashboard or a staff screen. Three things to look at before one goes on a portal:

1. **The customer condition.** The rule above. A staff report almost never has one, because staff are meant to see everything.
2. **The links.** Column formatting often builds links to staff screens, which lead a customer to the sign-in page. The markup renders correctly and the destination is still wrong.
3. **The columns.** Internal ids, owner names, margins and cost prices are all perfectly ordinary in a staff report, and none of them belong in front of a customer.

A copy of the report, written for the portal, is usually cleaner than trying to make one report serve both audiences.

## Checking it as a customer would see it

The builder's previews run your reports with **no customer in context**, which is the honest preview rather than an invented one: a report written against `{{ entity.id }}` previews as empty. That is not a fault, and a component that previews empty may be perfectly correct.

To see the real thing, use **Preview** to open the published portal and sign in as a test customer: a record on the customer entity, with an address you can receive mail at, marked active.
