Embed analytics in Next.js — 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.

In Next.js you do not create a separate server.mjs. The App Router file app/api/analytics/session/route.ts is your session-route BFF — it runs on the server, holds EMBED_API_KEY, and mints sessions for the browser.

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:3000.
  2. Analytics Step 3 → register → iframe parent origins: http://localhost:3000.
  3. Copy the control-plane embed token UUID — you will paste it into .env.local as NEXT_PUBLIC_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: .env.local (non-NEXT_PUBLIC_* vars) as EMBED_TOKEN (session mint body)
Browser: .env.local as NEXT_PUBLIC_EMBED_TOKEN / <EmbeddedAnalytics embedToken={…} /> as embedToken (or NEXT_PUBLIC_EMBED_TOKEN / VITE_EMBED_TOKEN)
—
EMBED_API_KEY ec_live_… from Analytics Step 4 Server only: .env.local (non-NEXT_PUBLIC_* vars) → 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: .env.local (non-NEXT_PUBLIC_* vars) 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 — Next.js session route (BFF) + SDK

Your laptop · follow in order

1. Scaffold the Next.js app + install SDKs

In a terminal, run:

npx create-next-app@latest my-analytics-next --typescript --eslint --app --no-src-dir --import-alias "@/*" --use-npm
cd my-analytics-next
npm install @embeddedcanvas/embed-sdk@^1.7.0 @superset-ui/embedded-sdk

If the CLI still prompts you, choose: App Router = Yes, src/ directory = No (this tutorial uses top-level app/), TypeScript = Yes. Tailwind / Turbopack are optional.

Stay in my-analytics-next for every remaining step. You should see:

my-analytics-next/
  app/
    layout.tsx
    page.tsx
  package.json
  …

2. Create .env.local (paste tokens)

In the project root (next to package.json), create a new file named exactly .env.local. Next.js loads this automatically for both the Route Handler and Client Components.

  1. Create the file: touch .env.local (or New File → .env.local).
  2. Paste the template below.
  3. Replace placeholders with Part I values:
    • EMBED_API_KEY ← Step 4 ec_live_… (server only — never NEXT_PUBLIC_)
    • EMBED_TOKEN and NEXT_PUBLIC_EMBED_TOKEN ← paste the same Step 3 UUID into both lines
    • NEXT_PUBLIC_TEST_CUSTOMER_ID ← a real warehouse tenant id (example: co_alltrade)
  4. Save. Confirm .env.local is gitignored (Next.js templates usually include it).
# Server only (Route Handler) — never prefix these with NEXT_PUBLIC_
EMBEDDED_CANVAS_API_URL=https://api.embeddedcanvas.com
EMBED_API_KEY=ec_live_PASTE_STEP4_KEY
EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID

# Browser-safe — same Step 3 UUID again (Next only exposes NEXT_PUBLIC_* to the client)
NEXT_PUBLIC_EMBED_TOKEN=PASTE_STEP3_EMBED_TOKEN_UUID

# RLS tenant sent by the page → session route (must match warehouse / tenant column values)
NEXT_PUBLIC_TEST_CUSTOMER_ID=CUST-1004
Why does the embed token appear twice? It is one dashboard embed token, not two secrets.
  • EMBED_TOKEN — Route Handler reads this and puts it in the session mint body.
  • NEXT_PUBLIC_EMBED_TOKEN — Client Component reads this and passes it to EmbeddedAnalytics.
Only names starting with NEXT_PUBLIC_ are available in browser code. Do not create NEXT_PUBLIC_EMBED_API_KEY.
RLS / empty charts NEXT_PUBLIC_TEST_CUSTOMER_ID is what the page sends as customerId. Builder “Company” filters as admin are not the same as the embed guest claim — use a tenant id that exists in your warehouse for the workspace tenant column. After editing .env.local, restart npm run dev.

3. Create the session Route Handler (BFF)

This file does not exist yet. Create the folders, then the file:

  1. From the project root:
    • macOS / Linux: mkdir -p app/api/analytics/session
    • Windows PowerShell: New-Item -ItemType Directory -Force app/api/analytics/session
  2. Create app/api/analytics/session/route.ts and paste all of the following:
import { NextResponse } from "next/server";

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();

export async function POST(req: Request) {
  if (!API_URL || !EMBED_API_KEY || !EMBED_TOKEN) {
    return NextResponse.json(
      { error: "Server embed env not configured (check .env.local)" },
      { status: 500 }
    );
  }

  const body = (await req.json()) as { customerId?: string; filters?: unknown };
  const customerId = body.customerId?.trim() ?? "";
  if (!customerId) {
    return NextResponse.json({ error: "customerId is required" }, { status: 400 });
  }
  // Production: assert customerId matches your authenticated user — never trust the browser alone.

  const payload: Record<string, unknown> = { embedToken: EMBED_TOKEN, customerId };
  if (body.filters) payload.filters = body.filters;

  const res = 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 NextResponse.json(await res.json(), { status: res.status });
}

Browser calls POST /api/analytics/session on your Next origin; this handler calls Embedded Canvas with the API key. No Vite proxy is required.

4. Create DashboardEmbed client component

  1. Create the folder if needed: mkdir -p app/components (PowerShell: New-Item -ItemType Directory -Force app/components).
  2. Create app/components/DashboardEmbed.tsx and paste:
"use client";

import { EmbeddedAnalytics } from "@embeddedcanvas/embed-sdk/react";
import { useMemo, useRef, useState } from "react";

type Props = {
  embedToken: string;
  customerId: string;
};

export default function DashboardEmbed({ embedToken, customerId }: Props) {
  const [city, setCity] = useState("");
  const [serviceLine, setServiceLine] = useState("");
  const filtersRef = useRef({ city: "", service_line: "" });
  const [nonce, setNonce] = useState(0);

  const fetchSession = useMemo(
    () => () => {
      const extra: Record<string, string> = {};
      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,
          ...(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;
      });
    },
    [customerId, nonce]
  );

  return (
    <div>
      <div style={{ display: "flex", gap: 8, marginBottom: 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={() => {
            filtersRef.current = { city, service_line: serviceLine };
            setNonce((n) => n + 1);
          }}
        >
          Apply
        </button>
      </div>
      <div style={{ minHeight: "70vh" }}>
        <EmbeddedAnalytics
          embedToken={embedToken}
          customerId={customerId}
          fetchSession={fetchSession}
          layoutMode="chromeless"
        />
      </div>
    </div>
  );
}

Do not hard-code secrets here. embedToken and customerId are passed in from the page (step 5). Apply bumps nonce so fetchSession remints with host-owned filters.

5. Wire it into app/page.tsx

Open app/page.tsx and replace all of its contents with:

import DashboardEmbed from "./components/DashboardEmbed";

export default function Home() {
  const embedToken = process.env.NEXT_PUBLIC_EMBED_TOKEN ?? "";
  const customerId = process.env.NEXT_PUBLIC_TEST_CUSTOMER_ID ?? "";

  if (!embedToken) {
    return (
      <main style={{ padding: 24 }}>
        <p>
          Missing <code>NEXT_PUBLIC_EMBED_TOKEN</code> in <code>.env.local</code>. Add it and restart{" "}
          <code>npm run dev</code>.
        </p>
      </main>
    );
  }

  if (!customerId) {
    return (
      <main style={{ padding: 24 }}>
        <p>
          Missing <code>NEXT_PUBLIC_TEST_CUSTOMER_ID</code> in <code>.env.local</code>. Set it to a real
          tenant id (same values as your warehouse tenant column), then restart <code>npm run dev</code>.
        </p>
      </main>
    );
  }

  return (
    <main style={{ padding: 16 }}>
      <h1 style={{ fontSize: 20, marginBottom: 12 }}>Embedded analytics</h1>
      <p style={{ marginBottom: 12, color: "#64748b", fontSize: 14 }}>
        Tenant: <code>{customerId}</code>
      </p>
      <DashboardEmbed embedToken={embedToken} customerId={customerId} />
    </main>
  );
}

Because this page is a Server Component by default, reading NEXT_PUBLIC_* here is fine — those values are inlined for the client tree. Secrets without NEXT_PUBLIC_ stay in the Route Handler only.

6. Run the app and verify

npm run dev
# Expect: Local http://localhost:3000
  1. Open http://localhost:3000.
  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 NEXT_PUBLIC_TEST_CUSTOMER_ID.
  4. Optional: type filter values that exist in your data → Apply.
If the embed is blank or charts say “No results”
  • Server embed env not configured → missing/wrong vars in .env.local; restart npm run dev.
  • EMBED_TOKEN ≠ NEXT_PUBLIC_EMBED_TOKEN → use the same Step 3 UUID on both lines.
  • Empty charts / RLS → wrong NEXT_PUBLIC_TEST_CUSTOMER_ID (builder Company filter ≠ embed claim).
  • Allowlist errors → Part I must list http://localhost:3000 in builder Allowed Domains and Step 3 iframe origins.
  • 404 on /api/analytics/session → route.ts path wrong; must be app/api/analytics/session/route.ts.
  • 405 Method Not Allowed → route.ts exists but is empty or missing export async function POST. Paste the full handler, save, restart npm run dev.

Final layout:

my-analytics-next/
  .env.local                              ← step 2
  app/
    api/analytics/session/route.ts        ← step 3 (BFF)
    components/DashboardEmbed.tsx         ← step 4
    page.tsx                              ← step 5
    layout.tsx

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 Next.js Route Handler (app/api/analytics/session/route.ts) 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 Next.js sample, Apply copies input values into filtersRef, then bumps nonce so fetchSession is recreated. The next mint POSTs { customerId, filters: { extra } } to the Route Handler, which forwards filters to Embedded Canvas. Typing alone does nothing until Apply increments nonce.

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.