Embed analytics in React — SDK + session route

Production path: your session-route BFF mints sessions; React mounts with mountAnalyticsEmbed.

SDK + session route — what is a BFF? A Backend-for-Frontend (BFF) is a thin server route owned by your app (Node, Next.js Route Handler, etc.) that mints embed sessions with your embed API key. The browser calls your session route (BFF); the BFF calls Embedded Canvas. That keeps 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

  1. Sign in at https://embeddedcanvas.com → open Analytics.

EC-2. Step 1 — Start analytics runtime

  1. Provision runtime until Status: active → Launch analytics builder.

EC-3. Step 2 — Configure workspace

  1. Connect warehouse → set Tenant column → Save.
  2. Confirm two test tenant ids with different row counts.

EC-4. Build dashboard + native filters

  1. Create a dataset/chart that changes between tenants → publish the dashboard.
  2. Add native filters in the builder for every column you will drive from your app UI later (tutorial examples: city, service_line, optional tech_name). These native filters are required even if you hide the filter bar — host-owned filters sync to them via filters.extra (see Host-owned filters).
  3. 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

  1. Builder → Embed dashboard → Allowed Domains: http://localhost:5173.
  2. Analytics Step 3 → register → iframe parent origins: http://localhost:5173.
  3. Copy the control-plane embed token UUID — you will paste it into Vite .env as VITE_EMBED_TOKEN (or constant) in Part II.
Dual allowlist Step 3 iframe parent origins and builder Allowed Domains must both list every host origin.

EC-6. Step 4 — Create embed API key

  1. Create embed API key → copy ec_live_… once and keep it for Part II (you will create the server env file there).
  2. Never put this key in Vite/React/Next public env (VITE_* / NEXT_PUBLIC_*), browser code, or git.

Values checklist

ValueWhere you get itWhere you put it
EMBED_TOKENAnalytics Step 3 — dashboard embed token UUIDClient (and server mint body on BFF)
CUSTOMER_IDTenant id from your warehouse / authServer derives; client may demo via input
EMBED_API_KEYAnalytics Step 4 — ec_live_…Server .env only
EMBEDDED_CANVAS_API_URLhttps://api.embeddedcanvas.comServer .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.

CredentialLooks likePut it hereNever 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
Integrator mental model 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
    …
Requirement Use Node 20+ so 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.

  1. 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.mjs in the project root
  2. Paste the entire contents below into server.mjs and 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.

  1. Create the file: touch .env (or New File → .env).
  2. Paste the template below.
  3. Replace the placeholders with the values you copied in Part I:
    • EMBED_API_KEY ← Step 4 ec_live_…
    • EMBED_TOKEN and VITE_EMBED_TOKEN ← paste the same Step 3 UUID into both lines (one token, two env names — see callout)
    • TEST_CUSTOMER_ID and VITE_TEST_CUSTOMER_ID ← same warehouse tenant id (example: co_alltrade)
  4. Save. Confirm .env is 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
Why does the embed token appear twice? It is one dashboard embed token (Step 3 UUID), not two different secrets.
  • EMBED_TOKEN — Node BFF reads this and sends it in the session mint body.
  • VITE_EMBED_TOKEN — React reads this via import.meta.env.VITE_EMBED_TOKEN and passes it to mountAnalyticsEmbed.
Vite never exposes non-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.
What reaches the browser? 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

  1. Open src/App.jsx.
  2. Replace all of its contents with the code below.
  3. Set both TEST_CUSTOMER_ID and VITE_TEST_CUSTOMER_ID in .env to a real tenant (example: co_alltrade). App.jsx sends VITE_TEST_CUSTOMER_ID as customerId — that value drives RLS, not the BFF default alone.
  4. Do not replace "YOUR_EMBED_TOKEN_UUID" if VITE_EMBED_TOKEN is set in .env — that string is only a fallback when the env var is missing. After changing .env, restart npm run dev so Vite picks it up.
  5. EMBED_TOKEN and VITE_EMBED_TOKEN must be the same Step 3 UUID (the dashboard you registered for this embed).
  6. 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
  1. Open http://localhost:5173 in the browser.
  2. The dashboard should load for tenantId / TEST_CUSTOMER_ID.
  3. Optional: type filter values that exist in your data → Apply (host-owned filters; see below).
If the embed is blank or errors
  • Configure .env from the BFF → missing/wrong EMBED_API_KEY, EMBED_TOKEN, or API URL.
  • Browser network /api/analytics/session fails → BFF not running, or Vite proxy not saved/restarted.
  • Iframe / allowlist errors → Part I dual allowlist must include http://localhost:5173.
  • VITE_EMBED_TOKEN empty → restart npm run dev after 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)

  1. Open the dashboard in the analytics builder (Part I).
  2. Add a native filter for each column your host UI will drive (examples used in these tutorials: city, service_line).
  3. 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).
  4. Publish / save the dashboard.
  5. Optional white-label: hide the native filter bar in the builder embed settings / keep layoutMode: "chromeless" (Part II samples already do this).
If host filters do nothing Usually the native filter was never created, the column name does not match the 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

  1. DevTools → Network → POST …/session (or your BFF path) → confirm JSON includes filters.extra.
  2. Type a value you know exists for the current tenant → Apply → chart numbers should change.
  3. Clear inputs → Apply → broader (or unfiltered) result for that tenant.
Mental model Native filters = definition in the builder (which columns can be filtered). Host-owned filters = your UI + filters.extra at mint time. Both are required for white-label filter UX on the SDK + session route path.