Embed analytics in Next.js — SDK + session route
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
- 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:3000. - Analytics Step 3 → register → iframe parent origins:
http://localhost:3000. - Copy the control-plane embed token UUID — you will paste it into
.env.localasNEXT_PUBLIC_EMBED_TOKENin 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: .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 |
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.
- Create the file:
touch .env.local(or New File →.env.local). - Paste the template below.
- Replace placeholders with Part I values:
EMBED_API_KEY← Step 4ec_live_…(server only — neverNEXT_PUBLIC_)EMBED_TOKENandNEXT_PUBLIC_EMBED_TOKEN← paste the same Step 3 UUID into both linesNEXT_PUBLIC_TEST_CUSTOMER_ID← a real warehouse tenant id (example:co_alltrade)
- Save. Confirm
.env.localis 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
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 toEmbeddedAnalytics.
NEXT_PUBLIC_ are available in browser code.
Do not create NEXT_PUBLIC_EMBED_API_KEY.
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:
-
From the project root:
- macOS / Linux:
mkdir -p app/api/analytics/session - Windows PowerShell:
New-Item -ItemType Directory -Force app/api/analytics/session
- macOS / Linux:
- Create
app/api/analytics/session/route.tsand 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
-
Create the folder if needed:
mkdir -p app/components(PowerShell:New-Item -ItemType Directory -Force app/components). - Create
app/components/DashboardEmbed.tsxand 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
- Open
http://localhost:3000. - Confirm the page shows your tenant id and the dashboard loads.
-
In DevTools → Network, find
POST /api/analytics/sessionand confirm the JSON body includes"customerId":"…"matchingNEXT_PUBLIC_TEST_CUSTOMER_ID. - Optional: type filter values that exist in your data → Apply.
Server embed env not configured→ missing/wrong vars in.env.local; restartnpm 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:3000in builder Allowed Domains and Step 3 iframe origins. 404on/api/analytics/session→route.tspath wrong; must beapp/api/analytics/session/route.ts.405 Method Not Allowed→route.tsexists but is empty or missingexport async function POST. Paste the full handler, save, restartnpm 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)
- 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 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
- 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.