Embed analytics in React — SDK + session route
Production path: your session-route BFF mints sessions; React mounts with mountAnalyticsEmbed.
ec_live_… off the client, binds customerId to your auth session, and is the
production-safe path for SaaS embeds.
Part I — Embedded Canvas setup
Control plane · do this first
EC-1. Sign in and open Analytics
- Sign in at
https://embeddedcanvas.com→ open Analytics.
EC-2. Step 1 — Start analytics runtime
- Provision runtime until Status: active → Launch analytics builder.
EC-3. Step 2 — Configure workspace
- Connect warehouse → set Tenant column → Save.
- Confirm two test tenant ids with different row counts.
EC-4. Build dashboard + native filters
- Create a dataset/chart that changes between tenants → publish the dashboard.
-
Add native filters in the builder for every column you will drive from your app UI later
(tutorial examples:
city,service_line, optionaltech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them viafilters.extra(see Host-owned filters). - For a white-label product UX, hide the native filter bar in the iframe and own filters in your app UI (Part II).
EC-5. Register dashboard & dual allowlist
- Builder → Embed dashboard → Allowed Domains:
http://localhost:5173. - Analytics Step 3 → register → iframe parent origins:
http://localhost:5173. - Copy the control-plane embed token UUID — you will paste it into Vite
.envasVITE_EMBED_TOKEN(or constant) in Part II.
EC-6. Step 4 — Create embed API key
- Create embed API key → copy
ec_live_…once and keep it for Part II (you will create the server env file there). - Never put this key in Vite/React/Next public env (
VITE_*/NEXT_PUBLIC_*), browser code, or git.
Values checklist
| Value | Where you get it | Where you put it |
|---|---|---|
EMBED_TOKEN | Analytics Step 3 — dashboard embed token UUID | Client (and server mint body on BFF) |
CUSTOMER_ID | Tenant id from your warehouse / auth | Server derives; client may demo via input |
EMBED_API_KEY | Analytics Step 4 — ec_live_… | Server .env only |
EMBEDDED_CANVAS_API_URL | https://api.embeddedcanvas.com | Server .env only |
Where to put each credential
Copy values from the Analytics wizard into the files below. Secrets stay on the server; the browser only gets the public dashboard embed token + short-lived session from your BFF.
| Credential | Looks like | Put it here | Never put it here |
|---|---|---|---|
EMBED_TOKEN |
UUID from Analytics Step 3 |
Server: BFF .env next to server.mjs as EMBED_TOKEN (session mint body)Browser: Vite .env as VITE_EMBED_TOKEN (or constant) / mountAnalyticsEmbed({ embedToken }) as embedToken (or NEXT_PUBLIC_EMBED_TOKEN / VITE_EMBED_TOKEN)
|
— |
EMBED_API_KEY |
ec_live_… from Analytics Step 4 |
Server only: BFF .env next to server.mjs → read in your BFF; send as Authorization: Bearer … |
Vite/React client code, NEXT_PUBLIC_*, browser localStorage, repo commits |
EMBEDDED_CANVAS_API_URL |
https://api.embeddedcanvas.com |
Server only: BFF .env next to server.mjs |
Client bundles (unless you only proxy via BFF — still prefer server) |
CUSTOMER_ID / customerId |
Tenant key from your auth / warehouse | Resolved in the BFF from the signed-in user, then returned/used in session mint. Demo UI may use a test input. | Hard-coded production tenant ids that customers can spoof without auth checks |
Session guestToken |
Short-lived token from BFF response | Memory only — SDK receives it via fetchSession; do not persist long-term |
Logs, analytics tools, permanent cookies |
ec_live_… unlocks minting on the server.
EMBED_TOKEN selects which dashboard to open (safe in the client).
customerId selects which tenant rows the guest token may see.
Part II — React + session route (BFF)
Your laptop · follow in order
You will create a Vite React app and a small Node file named server.mjs in the same folder.
That Node file is the BFF: the browser calls it; it calls Embedded Canvas with your API key.
1. Scaffold the Vite app + install SDKs
In a terminal, run (accept the Vite defaults if prompted):
npm create vite@latest my-analytics-app -- --template react
cd my-analytics-app
npm install
npm install @embeddedcanvas/embed-sdk@^1.7.0 @superset-ui/embedded-sdk
Stay in my-analytics-app for every remaining step.
Your folder should look roughly like:
my-analytics-app/
package.json
vite.config.js
index.html
src/
App.jsx
…
node --env-file=.env works when you start the BFF.
Check with node -v.
2. Create server.mjs (the BFF)
This file does not exist yet — Vite does not create it.
In the project root (my-analytics-app/, next to package.json), create a new file named exactly server.mjs.
- Create the empty file (pick one):
- macOS / Linux:
touch server.mjs - Windows PowerShell:
New-Item server.mjs - Or use your editor: New File → save as
server.mjsin the project root
- macOS / Linux:
- Paste the entire contents below into
server.mjsand save.
/**
* Session-route BFF — mints embed sessions with your embed API key.
* Run from this folder: npm run bff
* (uses Node's --env-file=.env; requires Node 20+)
*/
import http from "node:http";
const API_URL = (process.env.EMBEDDED_CANVAS_API_URL || "").replace(/\/$/, "");
const EMBED_API_KEY = process.env.EMBED_API_KEY?.trim();
const EMBED_TOKEN = process.env.EMBED_TOKEN?.trim();
const PORT = Number(process.env.PORT || 3090);
function json(res, status, body) {
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end(JSON.stringify(body));
}
const server = http.createServer(async (req, res) => {
if (req.method === "OPTIONS") return json(res, 204, {});
if (req.method === "POST" && req.url === "/api/analytics/session") {
if (!API_URL || !EMBED_API_KEY || !EMBED_TOKEN) {
return json(res, 500, { error: "Configure .env (EMBEDDED_CANVAS_API_URL, EMBED_API_KEY, EMBED_TOKEN)" });
}
let body = "";
for await (const chunk of req) body += chunk;
let customerId = process.env.TEST_CUSTOMER_ID || "CUST-1004";
let filters;
try {
const parsed = body ? JSON.parse(body) : {};
if (typeof parsed.customerId === "string" && parsed.customerId.trim()) {
customerId = parsed.customerId.trim();
}
filters = parsed.filters;
} catch {
return json(res, 400, { error: "Invalid JSON" });
}
const payload = { embedToken: EMBED_TOKEN, customerId };
if (filters) payload.filters = filters;
const upstream = await fetch(`${API_URL}/public/embed/v1/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${EMBED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
return json(res, upstream.status, await upstream.json());
}
json(res, 404, { error: "Not found" });
});
server.listen(PORT, () => {
console.log(`BFF listening on http://localhost:${PORT}`);
console.log(`POST http://localhost:${PORT}/api/analytics/session`);
});
Optional reference copy in this repo: examples/integrator/node-bff/server.mjs
(same idea; this tutorial keeps the file inside your Vite app so one project runs both processes).
3. Create project-root .env (paste tokens)
Still in the project root (same folder as server.mjs and package.json), create a file named .env.
Use one .env for both the BFF and Vite.
- Create the file:
touch .env(or New File →.env). - Paste the template below.
- Replace the placeholders with the values you copied in Part I:
EMBED_API_KEY← Step 4ec_live_…EMBED_TOKENandVITE_EMBED_TOKEN← paste the same Step 3 UUID into both lines (one token, two env names — see callout)TEST_CUSTOMER_IDandVITE_TEST_CUSTOMER_ID← same warehouse tenant id (example:co_alltrade)
- Save. Confirm
.envis listed in.gitignore(Vite’s template usually includes it).
# Server only — read by: npm run bff (node --env-file=.env server.mjs)
EMBEDDED_CANVAS_API_URL=https://api.embeddedcanvas.com
EMBED_API_KEY=ec_live_PASTE_STEP4_KEY
EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
PORT=3090
TEST_CUSTOMER_ID=CUST-1004
# Same Step 3 UUID again — Vite only injects names that start with VITE_ into src/
VITE_EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID
# Same tenant as TEST_CUSTOMER_ID — App.jsx reads this (body overrides BFF default)
VITE_TEST_CUSTOMER_ID=CUST-1004
EMBED_TOKEN— Node BFF reads this and sends it in the session mint body.VITE_EMBED_TOKEN— React reads this viaimport.meta.env.VITE_EMBED_TOKENand passes it tomountAnalyticsEmbed.
VITE_* vars to the browser, so the client line needs the VITE_ prefix.
Do not create a VITE_EMBED_API_KEY — the API key stays server-only.
VITE_* values are bundled into client code.
EMBED_API_KEY stays on the server as long as you never reference it from src/.
4. Add the bff script to package.json
Open package.json. Inside the existing "scripts" object, add the bff line
(keep dev / build / preview). Example:
File: package.json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"bff": "node --env-file=.env server.mjs"
}
}
Save the file. From now on npm run bff starts server.mjs with the .env you created.
5. Proxy /api/… in vite.config.js
The React app will call fetch("/api/analytics/session") on the Vite origin (http://localhost:5173).
Vite must forward that path to the BFF on port 3090.
Open vite.config.js and replace it with:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api/analytics/session": {
target: "http://localhost:3090",
changeOrigin: true,
},
},
},
});
If your template created vite.config.ts instead, use the same server.proxy block there.
6. Replace src/App.jsx
- Open
src/App.jsx. - Replace all of its contents with the code below.
-
Set both
TEST_CUSTOMER_IDandVITE_TEST_CUSTOMER_IDin.envto a real tenant (example:co_alltrade).App.jsxsendsVITE_TEST_CUSTOMER_IDascustomerId— that value drives RLS, not the BFF default alone. -
Do not replace
"YOUR_EMBED_TOKEN_UUID"ifVITE_EMBED_TOKENis set in.env— that string is only a fallback when the env var is missing. After changing.env, restartnpm run devso Vite picks it up. -
EMBED_TOKENandVITE_EMBED_TOKENmust be the same Step 3 UUID (the dashboard you registered for this embed). - Save. You can leave or delete unused starter CSS imports if the default template complains — keep the mount working first.
import { useEffect, useMemo, useRef, useState } from "react";
import { mountAnalyticsEmbed } from "@embeddedcanvas/embed-sdk";
const EMBED_TOKEN = import.meta.env.VITE_EMBED_TOKEN || "YOUR_EMBED_TOKEN_UUID";
// RLS tenant — must match warehouse values for your workspace tenant column
const tenantId = import.meta.env.VITE_TEST_CUSTOMER_ID || "CUST-1004";
export default function App() {
const mountRef = useRef(null);
const controllerRef = useRef(null);
const [city, setCity] = useState("");
const [serviceLine, setServiceLine] = useState("");
const filtersRef = useRef({ city: "", service_line: "" });
const fetchSession = useMemo(
() => () => {
const extra = {};
for (const [k, v] of Object.entries(filtersRef.current)) {
if (v.trim()) extra[k] = v.trim();
}
return fetch("/api/analytics/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerId: tenantId,
...(Object.keys(extra).length ? { filters: { extra } } : {}),
}),
}).then(async (res) => {
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Session mint failed");
return data;
});
},
[]
);
useEffect(() => {
const el = mountRef.current;
if (!el) return;
let cancelled = false;
mountAnalyticsEmbed({
embedToken: EMBED_TOKEN,
customerId: tenantId,
mountPoint: el,
fetchSession,
layoutMode: "chromeless",
}).then((c) => {
if (cancelled) c.destroy();
else controllerRef.current = c;
});
return () => {
cancelled = true;
controllerRef.current?.destroy();
};
}, [fetchSession]);
async function applyFilters() {
filtersRef.current = { city, service_line: serviceLine };
const extra = {};
for (const [k, v] of Object.entries(filtersRef.current)) {
if (v.trim()) extra[k] = v.trim();
}
await controllerRef.current?.setFilters(Object.keys(extra).length ? { extra } : {});
}
return (
<div>
<div style={{ display: "flex", gap: 8, padding: 12 }}>
<input value={city} onChange={(e) => setCity(e.target.value)} placeholder="city" />
<input value={serviceLine} onChange={(e) => setServiceLine(e.target.value)} placeholder="service_line" />
<button type="button" onClick={applyFilters}>Apply</button>
</div>
<div ref={mountRef} style={{ minHeight: "70vh" }} />
</div>
);
}
7. Run BFF + Vite and open the app
You need two terminals, both in my-analytics-app:
# Terminal 1 — session-route BFF (must stay running)
npm run bff
# Expect: BFF listening on http://localhost:3090
# Terminal 2 — Vite React app
npm run dev
# Expect: Local http://localhost:5173
- Open
http://localhost:5173in the browser. - The dashboard should load for
tenantId/TEST_CUSTOMER_ID. - Optional: type filter values that exist in your data → Apply (host-owned filters; see below).
Configure .envfrom the BFF → missing/wrongEMBED_API_KEY,EMBED_TOKEN, or API URL.- Browser network
/api/analytics/sessionfails → BFF not running, or Vite proxy not saved/restarted. - Iframe / allowlist errors → Part I dual allowlist must include
http://localhost:5173. VITE_EMBED_TOKENempty → restartnpm run devafter editing.env.
Final layout:
my-analytics-app/
.env ← created in step 3 (secrets + VITE_EMBED_TOKEN)
server.mjs ← created in step 2 (BFF)
package.json ← "bff" script added in step 4
vite.config.js ← proxy added in step 5
src/App.jsx ← replaced in step 6
Host-owned filters (non-native UI) — step by step
White-label filter UX
Native filters are the filter controls inside the analytics builder / iframe.
Host-owned filters are inputs in your app (the city / service_line boxes in Part II).
Your app collects values → sends them as filters.extra on session mint → Embedded Canvas
applies them and syncs matching native filters. End users never need the native filter bar.
F-1. Create matching native filters in the builder (required)
- Open the dashboard in the analytics builder (Part I).
- Add a native filter for each column your host UI will drive
(examples used in these tutorials:
city,service_line). -
The filter must target the same dataset column you will send in
filters.extra. Keys are matched by column name (case/spelling must match warehouse values too). - Publish / save the dashboard.
-
Optional white-label: hide the native filter bar in the builder embed settings / keep
layoutMode: "chromeless"(Part II samples already do this).
filters.extra key, or the typed value does not exist in the data (wrong spelling/case).
F-2. Collect values in your app UI
Part II already adds simple inputs (e.g. city, service_line) and an Apply button. Those are host-owned filters — not Superset UI.
F-3. Send values on session mint as filters.extra
Your session-route BFF (server.mjs) must include non-empty host values under filters.extra
when calling POST /public/embed/v1/sessions (directly or via your BFF):
{
"embedToken": "…",
"customerId": "co_alltrade",
"filters": {
"extra": {
"city": "Denver",
"service_line": "Electrical"
}
}
}
In Part II sample apps, the browser posts that shape to /api/analytics/session;
the BFF forwards filters to Embedded Canvas.
Empty strings are omitted — only filled inputs are sent.
F-4. On Apply, remint (do not only update local React/Vue state)
In the React sample, Apply builds { extra: { city, service_line } } from the inputs, then calls await controllerRef.current?.setFilters({ extra }). The SDK remints via your BFF (POST /api/analytics/session with filters.extra). Do not only setState the inputs — charts will not change until setFilters runs.
Changing an input alone does nothing until Apply remints the guest session with the new
filters.extra. Charts then reload under RLS + the new filter values.
F-5. Verify
- DevTools → Network →
POST …/session(or your BFF path) → confirm JSON includesfilters.extra. - Type a value you know exists for the current tenant → Apply → chart numbers should change.
- Clear inputs → Apply → broader (or unfiltered) result for that tenant.
filters.extra at mint time.
Both are required for white-label filter UX on the SDK + session route path.