Our numbers were already clean and governed, sitting behind a Power BI semantic model. What they didn't have was a home our staff actually wanted to open. The stock Power BI portal is fine — it just isn't ours, and "go log into that other tool" is where good analytics quietly goes to be ignored.
So EXO grew out of one ambition: surface that governed data inside a custom React app that feels like the rest of what we build — our layout, our typography, Apache ECharts instead of Power BI's chrome. The charts were never the hard part. The hard part was the thin line between data a browser must never touch directly and an experience we wanted to control completely. The received wisdom for crossing that line is heavier than the job needs. This is the light way we found.
01 · THE PROBLEMGoverned data, our own front end, and a browser that can't be trusted
The data lives behind a Power BI semantic model — the governed layer with its measures, relationships and row-level security defined once. EXO doesn't re-model anything; it reads that model and puts it in front of people.
The moment you decide the experience will be a React app rather than the stock Power BI service, three questions land on your desk at once. If our app draws the dashboards, how does it get numbers out of a Power BI semantic model at all? How does each person see only the rows their role permits? And how does any of that happen without a Power BI or Fabric secret ending up somewhere a browser could read it?
And a fourth, practical one that ambushes everyone: Power BI's REST API has no CORS. A browser literally cannot call it. So even before security, there is a plumbing wall between our React app and the data.
02 · THE WRONG TURNThe two heavy defaults
Two answers come up again and again, both heavier than the problem.
The first is the documented app-owns-data embed: a service principal owns the reports, the backend calls GenerateToken to mint a short-lived embed token with an "effective identity" stamped on it, and the browser embeds a Power BI report. It works — but it drags a service-principal secret, Key Vault, and a whole token-minting machinery into your build, and, crucially, you're still rendering Power BI's report inside an iframe. That's not our app; it's Power BI wearing our header.
The second is to abandon Power BI and rebuild every dashboard against raw data APIs — which throws away the semantic model, the measures, and the row-level security we'd already paid for, and replaces them with a charting library and a standing promise to reimplement governance by hand, forever.
Architecture decision
We set both aside. We wanted the governance of the semantic model and a UI that is genuinely our own React app with ECharts — without a service-principal secret to guard and without discarding the model. Neither default gives you both.
There was a quieter worry underneath: people assumed bridging "our edge app" and "the data behind Azure" meant a fat gateway or a secret-holding backend. It didn't. It meant one honest server-side seam that holds nothing — which is the whole point of what follows.
03 · THE KEY INSIGHTForward the user's own token; let Power BI do the security
Here's the move we'd nearly talked ourselves past. Power BI's executeQueries endpoint accepts a delegated user token — the ordinary OAuth2 access token the person already gets when they sign in. If we forward that, Power BI evaluates the query as the actual person, and the row-level security defined once in the model applies to them automatically. No service principal. No embed token. No effective identity to stamp — because the identity is the real user.
So the relay needs no data secret of its own. It only has to verify the caller's own token — signature, tenant, audience — and forward it, verbatim, to Power BI.
No service principal. No embed token. The relay hands out nothing it doesn't already have — it just proves the token is real and passes it on. Power BI, not our code, decides which rows come back.
That relay is a Backend-for-Frontend — a same-origin Next.js API route. It exists for two honest reasons: Power BI has no CORS (so the browser can't call it), and we want the token verified before it leaves for Power BI. It carries the entire design, and it is about forty lines.
Auth, precisely: corporate SSO — SAML-federated into Microsoft Entra ID — signs the person in. In the browser, MSAL (OIDC / OAuth2 with PKCE, no client secret) then acquires a delegated access token for the Power BI API. That token — the user's, scoped to them — is what rides through the relay.
// POST /api/fabric/execute-queries — the relay that owns no secret (abridged) export async function POST(req: Request) { // 1. verify the caller's OWN delegated Entra token const token = bearer(req); const { payload } = await jwtVerify(token, jwks, { issuer: [ `https://sts.windows.net/${TENANT}/`, // Power BI issues v1 tokens... `https://login.microsoftonline.com/${TENANT}/v2.0`, // ...under an MSAL v2 authority ], }); if (payload.tid !== TENANT || !audienceIsPowerBI(payload)) return unauthorized(); // 2. only registered models may be queried (config-driven allow-list) if (!ALLOWED_MODELS.has(`${workspaceId}/${datasetId}`)) return forbidden(); // 3. forward the user's OWN token — RLS applies to THEM const path = workspaceId ? `groups/${workspaceId}/datasets/${datasetId}` // shared workspace : `datasets/${datasetId}`; // "" = My workspace const r = await fetch(`https://api.powerbi.com/v1.0/myorg/${path}/executeQueries`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, // verbatim — no secret of ours body: JSON.stringify({ queries: [{ query: dax }] }), }); return new Response(await r.text(), { status: r.status }); }
The relay verifies and forwards. It never mints, stores, or owns a credential of its own. Contrast the heavy default: a service principal + Key Vault + GenerateToken.
04 · THE ARCHITECTUREWhat actually sits in the seam
The important read is what isn't there: no service principal to guard, no embed tokens, no iframe of someone else's UI, no second warehouse. A React app, a relay that forwards a token, a semantic model, and — for realtime — a second relay to a sensor API.
Three surfaces, one pattern
Strip everything else away and EXO offers three capabilities, each mapped to a source and a query mode. All three reach their data through a same-origin relay, and none of them puts a credential where a browser could find it.
executeQueries → rendered in Apache ECharts, our brand end to end.05 · THE FLOWOne sign-in to a rendered chart
Put the pieces in motion and this is what a staff member sets off when they open a hall. Five moves, most of them invisible, and the only server-side step is verify-and-forward.
06 · DAX → ECHARTSThe semantic layer, without the iframe
This is the part that makes it our app. We don't embed a report; we query the semantic model with DAX and draw the result ourselves.
-- a measure query is just DAX over the governed model EVALUATE ROW( "Active", [# Active Subscribers], "New30", [# New Subscribers(30 Days)], "Unsub30", [# Un-Subscribers(30 Days)] )
The measures were defined once, in the model. We just ask for them by name.
// the hook runs it through the relay; the component draws ECharts const kpi = usePowerBiQuery(KPI_QUERY, { workspaceId, datasetId }); const option = useMemo<EChartsOption>(() => ({ series: [{ type: "radar", data: sites.map(s => ({ name: s.site, value: profile(s) })) }], }), [kpi.data]); return <EChart option={option} />; // our chart, our brand, the model's numbers
Because we own the rendering, we get a real design system — brand colours, a radar of site profiles, a gradient trend, semantic-coloured bars — and we validated the categorical palette for colour-blind separation (which is why the overlaid radar caps at three series). Power BI keeps the numbers honest; ECharts makes them ours.
How do you know what to query in a model you didn't build?
We wrote a model explorer: point a new model at it and it introspects the schema with DAX and lists every measure and field.
EVALUATE SELECTCOLUMNS(INFO.VIEW.MEASURES(), "Name",[Name], "Table",[Table], "Expression",[Expression])
A war story lives here — see §08.
07 · REALTIME WITH BIONICA second source, the same discipline
Not everything is Power BI. Live visitor occupancy comes from Bionic people-counters via the Skyfii REST API — a completely different shape: a per-minute occupancy series behind HTTP Basic auth.
The dashboard polls every 30 seconds and renders a live "on site now" number and an intraday curve in ECharts. Same museum, same brand, a totally different backend — and the front end never knows the difference.
One app, two data worlds — a governed semantic model and a live sensor feed — behind two relays that share one rule: the secret never reaches the glass.
08 · THE FABRIC PATHWhat we actually hit, and how we got through
The clean story above was earned. The real ones:
- No CORS on Power BI REST. The browser can't call
executeQueries. The same-origin relay exists precisely to be the thing that can. - v1 vs v2 tokens. Power BI often issues v1 (
sts.windows.net) tokens even when MSAL signs in against a v2 authority. Verification failed until we accepted both issuer forms for the tenant. .defaultscope. Requesting the specificDataset.Read.Allscope was brittle across the tenant; switching the token request to.defaultmade it carry every consented Power BI permission — and let discovery and embed light up later with zero code change.- "Invalid dataset or workspace." A model opened from
groups/me/…isn't in a shared workspace — it's in My workspace, which uses a group-less endpoint (/myorg/datasets/{id}), not/groups/{id}/datasets/{id}. We taught the relay both, keyed off an emptyworkspaceId. The error flipped to "Connected." INFO.MEASURES()failing. PlainINFO.*introspection errored on the model; the friendlierINFO.VIEW.*functions returned name-keyed rows and worked — so the explorer reads schema reliably.- DAX on every view. Live-querying per page view doesn't scale. We added a read-through cache in MongoDB (per-model TTL, fail-open — if the cache is down, it silently queries live), so most views run no DAX.
- Secrets in
.env.local. Gitignored locally means not on Vercel — the Bionic dashboard 500'd in prod until the env vars were set in the deployment. Config is code's job; secrets are the environment's job.
The design principles that fell out of all this
Models are config, not code SERVER
Every hall and model — its IDs, access rule, cache TTL — is declared in config/models.json, zod-validated at load. Adding a dashboard's data is a config edit; only its bespoke chart is code.
source: config/models.json → allow-list → relay
Where the security is decided POWER BI
We never compute "which rows." The relay forwards the user's real token; the semantic model's RLS decides. The app can't over-ask, because it isn't the one asking.
the model asserts authority · our code asserts nothing
The relay owns nothing TOKEN
For the Power BI path, no secret crosses into our code at all. What crosses the wire is the user's own short-lived token — and DAX results already scoped to them.
verified once · forwarded once · thrown away
The "it just appears" bonus
Because the token is the user's own, a /api/fabric/discover call lists exactly the reports they're allowed to see — segregation for free — and any we don't hand-build can be embedded with their AAD token (user-owns-data embed) rather than a service-principal one.
09 · WHAT'S SETTLEDThe decisions we are building on
- Power BI is queried with delegated user tokens through a same-origin relay — no service principal, no embed-token machinery for the core path.
- Dashboards are custom React + Apache ECharts over DAX — not embedded reports; the semantic model stays the governed source.
- RLS is enforced by Power BI against the real user, not stamped by us.
- Models are config (
config/models.json); secrets are env. - A read-through Mongo cache (fail-open) keeps DAX off the per-view path.
- Realtime (Bionic) rides a second, credential-holding, tenant-gated relay.
Open, deliberately
- A scheduled service-principal refresh to warm the cache off-hours — so even the first viewer never waits on DAX. Designed, not yet built.
- Turning the discovery halls fully on needs
Report.Read.All+ workspace read consented, and a real roles/groups claim from Entra to make card-level segregation a hard boundary rather than a convenience. - Whether any model carries per-user RLS — which must opt out of the shared cache.