Embed analytics in Vue — SDK + session route

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.

Same pattern as the React tutorial: a Vite Vue app plus a small Node server.mjs BFF in the same folder. The browser calls your BFF; the BFF mints sessions with your embed API key.

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 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 / createVueEmbedMount / embed.mount({ 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 — Vue + session route (BFF)

Your laptop · follow in order

1. Scaffold the Vite Vue app + install SDKs

In a terminal, run (accept the Vite defaults if prompted):

npm create vite@latest my-analytics-vue -- --template vue
cd my-analytics-vue
npm install
npm install @embeddedcanvas/embed-sdk@^1.7.0 @superset-ui/embedded-sdk

Stay in my-analytics-vue for every remaining step. Your folder should look roughly like:

my-analytics-vue/
  package.json
  vite.config.js
  index.html
  src/
    App.vue
    main.js
    …
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-vue/, 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 (do not leave the file empty).
/**
 * 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 placeholders with Part I values:
    • EMBED_API_KEY ← Step 4 ec_live_…
    • EMBED_TOKEN and VITE_EMBED_TOKEN ← paste the same Step 3 UUID into both lines
    • 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.vue reads this (body overrides BFF default)
VITE_TEST_CUSTOMER_ID=CUST-1004
Why VITE_ and not VUE_? This app is scaffolded with Vite (npm create vite … --template vue). Vite only injects env vars that start with VITE_ into import.meta.env. A name like VUE_TEST_CUSTOMER_ID would not be available in src/App.vue. (Older Vue CLI projects used VUE_APP_* — that does not apply here.)
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 — Vue reads this via import.meta.env.VITE_EMBED_TOKEN for embed.mount.
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.
RLS / empty charts App.vue sends VITE_TEST_CUSTOMER_ID as customerId — that value drives RLS, not the BFF TEST_CUSTOMER_ID default alone. Builder “Company” filters as admin are not the same as the embed guest claim. After editing .env, restart npm run bff and npm run dev.

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 Vue 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 (or vite.config.ts) and replace it with:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      "/api/analytics/session": {
        target: "http://localhost:3090",
        changeOrigin: true,
      },
    },
  },
});

If your file is TypeScript, keep the same server.proxy block. Do not remove the vue() plugin — the Vite Vue template needs it.

6. Replace src/App.vue

  1. Open src/App.vue.
  2. Replace all of its contents with the code below.
  3. 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.
  4. EMBED_TOKEN and VITE_EMBED_TOKEN must be the same Step 3 UUID.
  5. Save. You can ignore unused starter CSS in main.js for now.
<script setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
import { createVueEmbedMount } from "@embeddedcanvas/embed-sdk/vue";

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 CUSTOMER_ID = import.meta.env.VITE_TEST_CUSTOMER_ID || "CUST-1004";

const city = ref("");
const serviceLine = ref("");
const mountEl = ref(null);
const filtersRef = { city: "", service_line: "" };
const embed = createVueEmbedMount(() => mountEl.value);

async function mountEmbed() {
  await embed.mount({
    embedToken: EMBED_TOKEN,
    customerId: CUSTOMER_ID,
    layoutMode: "chromeless",
    fetchSession: () => {
      const extra = {};
      for (const [k, v] of Object.entries(filtersRef)) {
        if (v.trim()) extra[k] = v.trim();
      }
      return fetch("/api/analytics/session", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customerId: CUSTOMER_ID,
          ...(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;
      });
    },
  });
}

async function applyFilters() {
  filtersRef.city = city.value;
  filtersRef.service_line = serviceLine.value;
  const extra = {};
  for (const [k, v] of Object.entries(filtersRef)) {
    if (v.trim()) extra[k] = v.trim();
  }
  await embed.getController()?.setFilters(Object.keys(extra).length ? { extra } : {});
}

onMounted(() => {
  void mountEmbed();
});
onBeforeUnmount(() => embed.destroy());
</script>

<template>
  <div>
    <p style="padding: 12px 12px 0; color: #64748b; font-size: 14px">
      Tenant: <code>{{ CUSTOMER_ID }}</code>
    </p>
    <div style="display: flex; gap: 8px; padding: 12px">
      <input v-model="city" placeholder="city" />
      <input v-model="serviceLine" placeholder="service_line" />
      <button type="button" @click="applyFilters">Apply</button>
    </div>
    <div ref="mountEl" style="min-height: 70vh" />
  </div>
</template>

7. Run BFF + Vite and open the app

You need two terminals, both in my-analytics-vue:

# Terminal 1 — session-route BFF (must stay running)
npm run bff
# Expect: BFF listening on http://localhost:3090

# Terminal 2 — Vite Vue app
npm run dev
# Expect: Local http://localhost:5173
  1. Open http://localhost:5173 in the browser.
  2. Confirm the page shows your tenant id and the dashboard loads.
  3. In DevTools → Network, find POST /api/analytics/session and confirm the JSON body includes "customerId":"…" matching VITE_TEST_CUSTOMER_ID, and the response is 200.
  4. Optional: type filter values that exist in your data → Apply.
If the embed is blank or charts say “No results”
  • Configure .env from the BFF → missing/wrong EMBED_API_KEY, EMBED_TOKEN, or API URL; or server.mjs left empty.
  • Browser network /api/analytics/session fails → BFF not running, or Vite proxy not saved/restarted.
  • EMBED_TOKEN ≠ VITE_EMBED_TOKEN → use the same Step 3 UUID on both lines.
  • Empty charts / RLS → wrong VITE_TEST_CUSTOMER_ID (builder Company filter ≠ embed claim).
  • Iframe / allowlist errors → Part I dual allowlist must include http://localhost:5173.
  • VITE_* empty → restart npm run dev after editing .env.

Final layout:

my-analytics-vue/
  .env                 ← created in step 3 (secrets + VITE_*)
  server.mjs           ← created in step 2 (BFF) — must not be empty
  package.json         ← "bff" script added in step 4
  vite.config.js       ← proxy added in step 5
  src/App.vue          ← 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 Vue sample, Apply copies city / serviceLine into filtersRef, then calls await embed.getController()?.setFilters({ extra }). The SDK remints through your BFF with filters.extra. Updating v-model alone does not remint — you must click Apply.

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.