Embedding Analytics

Embedding Metabase in a React App

Embedding Metabase in a React application involves two parts: a server-side API route that generates a signed JWT embed URL, and a React component tha...

šŸ“…
šŸ“–9 min read

Embedding Metabase in a React App

Embedding Metabase in a React application involves two parts: a server-side API route that generates a signed JWT embed URL, and a React component that fetches that URL and renders it in an iframe. React's component model makes it straightforward to build reusable, props-driven analytics components that can be dropped anywhere in your application.

This guide covers a complete implementation: the API route, the React component, TypeScript types, loading states, error handling, dynamic iframe height, and a pattern for embedding multiple different dashboards throughout your app.

---

Architecture Overview

React App                           Your API Server          Metabase

│ │ │ │ 1. Component mounts │ │ │ 2. Fetch /api/embed-url │ │ │ (with dashboardId, params) ───▶ │ │ │ │ 3. Verify user auth │ │ │ 4. Generate JWT │ │ │ 5. Return embed URL │ │ ◀──────────────────────────────── │ │ │ 6. Set iframe src = embed URL ──────────────────────────▶ │ │ │ 7. Validate JWT │ │ │ 8. Execute queries │ │ ◀──────────────────────────────────────────────────────── │ │ 9. Render dashboard │ │

The React component never handles the JWT secret. It fetches an already-signed URL from your API, which is the only place the secret lives.

---

Part 1: The API Route

Next.js (App Router)

typescript

// app/api/analytics/embed-url/route.ts import { NextRequest, NextResponse } from "next/server"; import jwt from "jsonwebtoken"; import { getCurrentUser } from "@/lib/auth";

const METABASE_SITE_URL = process.env.METABASE_SITE_URL!; const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY!;

export async function GET(request: NextRequest) { // Verify the user is authenticated const user = await getCurrentUser(request); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }

const { searchParams } = new URL(request.url); const dashboardId = searchParams.get("dashboardId");

if (!dashboardId) { return NextResponse.json({ error: "Missing dashboardId" }, { status: 400 }); }

// Build locked params based on the authenticated user const lockedParams: Record<string, string | number> = { organization_id: user.organizationId, };

const payload = { resource: { dashboard: parseInt(dashboardId, 10) }, params: lockedParams, exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 min expiry };

const token = jwt.sign(payload, METABASE_SECRET_KEY); const embedUrl = ${METABASE_SITE_URL}/embed/dashboard/${token}#bordered=false&titled=false;

return NextResponse.json({ embedUrl }); }

Next.js (Pages Router)

typescript

// pages/api/analytics/embed-url.ts import type { NextApiRequest, NextApiResponse } from "next"; import jwt from "jsonwebtoken"; import { getCurrentUser } from "@/lib/auth";

export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "GET") { return res.status(405).json({ error: "Method not allowed" }); }

const user = await getCurrentUser(req); if (!user) { return res.status(401).json({ error: "Unauthorized" }); }

const { dashboardId } = req.query; if (!dashboardId || typeof dashboardId !== "string") { return res.status(400).json({ error: "Missing dashboardId" }); }

const payload = { resource: { dashboard: parseInt(dashboardId, 10) }, params: { organization_id: user.organizationId }, exp: Math.round(Date.now() / 1000) + 10 * 60, };

const token = jwt.sign(payload, process.env.METABASE_SECRET_KEY!); const embedUrl = ${process.env.METABASE_SITE_URL}/embed/dashboard/${token}#bordered=false&titled=false;

return res.status(200).json({ embedUrl }); }

---

Part 2: The React Component

Basic Component

typescript

// components/MetabaseDashboard.tsx "use client"; // Next.js App Router: this is a client component

import { useState, useEffect, useRef } from "react";

interface MetabaseDashboardProps { dashboardId: number; height?: number | string; className?: string; loadingFallback?: React.ReactNode; errorFallback?: React.ReactNode; }

export function MetabaseDashboard({ dashboardId, height = 600, className = "", loadingFallback, errorFallback, }: MetabaseDashboardProps) { const [embedUrl, setEmbedUrl] = useState<string | null>(null); const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); const iframeRef = useRef<HTMLIFrameElement>(null);

useEffect(() => { let cancelled = false;

async function fetchEmbedUrl() { try { const res = await fetch( /api/analytics/embed-url?dashboardId=${dashboardId}, { credentials: "include" } );

if (!res.ok) throw new Error(Failed to fetch embed URL: ${res.status});

const { embedUrl } = await res.json(); if (!cancelled) { setEmbedUrl(embedUrl); } } catch (err) { if (!cancelled) { setStatus("error"); } } }

fetchEmbedUrl(); return () => { cancelled = true; }; }, [dashboardId]);

// Listen for Metabase's postMessage to auto-resize the iframe useEffect(() => { function handleMessage(event: MessageEvent) { const metabaseSiteUrl = process.env.NEXT_PUBLIC_METABASE_SITE_URL; if (event.origin !== metabaseSiteUrl) return;

const { metabase } = event.data || {}; if ( metabase?.type === "frame" && metabase?.context === "dashboard" && iframeRef.current ) { iframeRef.current.style.height = ${metabase.height}px; } }

window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, []);

if (status === "error") { return errorFallback ?? ( <div className={analytics-error ${className}}> Analytics are temporarily unavailable. </div> ); }

if (!embedUrl || status === "loading") { return loadingFallback ?? ( <div className={analytics-loading ${className}} style={{ height }}> Loading analytics... </div> ); }

return ( <iframe ref={iframeRef} src={embedUrl} style={{ width: "100%", height: typeof height === "number" ? ${height}px : height, border: "none", }} className={className} onLoad={() => setStatus("ready")} allowTransparency title="Analytics Dashboard" /> ); }

Usage

tsx

// pages/analytics.tsx or app/analytics/page.tsx

import { MetabaseDashboard } from "@/components/MetabaseDashboard";

// Dashboard IDs defined as constants — change these to your actual IDs const DASHBOARDS = { OVERVIEW: 42, REVENUE: 43, USAGE: 44, } as const;

export default function AnalyticsPage() { return ( <div className="max-w-7xl mx-auto px-4 py-8"> <h1 className="text-2xl font-semibold mb-6">Analytics</h1> <MetabaseDashboard dashboardId={DASHBOARDS.OVERVIEW} height={700} /> </div> ); }

---

Part 3: Advanced Patterns

Multiple Dashboard Tabs

tsx

// components/AnalyticsTabs.tsx import { useState } from "react"; import { MetabaseDashboard } from "./MetabaseDashboard";

const TABS = [ { id: "overview", label: "Overview", dashboardId: 42 }, { id: "revenue", label: "Revenue", dashboardId: 43 }, { id: "usage", label: "Usage", dashboardId: 44 }, ];

export function AnalyticsTabs() { const [activeTab, setActiveTab] = useState(TABS[0].id); const activeTabData = TABS.find((t) => t.id === activeTab)!;

return ( <div> <div className="tab-bar"> {TABS.map((tab) => ( <button key={tab.id} onClick={() => setActiveTab(tab.id)} className={tab.id === activeTab ? "active" : ""} > {tab.label} </button> ))} </div> {/<em class="italic"> Key forces remount (and new URL fetch) when tab changes </em>/} <MetabaseDashboard key={activeTabData.dashboardId} dashboardId={activeTabData.dashboardId} height={650} /> </div> ); }

Pre-fetching Embed URLs

For faster perceived performance, pre-fetch embed URLs when the page loads rather than waiting for the component to mount:

typescript

// hooks/useEmbedUrl.ts import { useState, useEffect } from "react";

const cache = new Map<number, string>();

export function useEmbedUrl(dashboardId: number) { const [embedUrl, setEmbedUrl] = useState<string | null>( cache.get(dashboardId) ?? null ); const [error, setError] = useState<Error | null>(null);

useEffect(() => { if (cache.has(dashboardId)) { setEmbedUrl(cache.get(dashboardId)!); return; }

fetch(/api/analytics/embed-url?dashboardId=${dashboardId}, { credentials: "include", }) .then((res) => res.json()) .then(({ embedUrl }) => { cache.set(dashboardId, embedUrl); setEmbedUrl(embedUrl); }) .catch(setError); }, [dashboardId]);

return { embedUrl, error }; }

Note: Cache carefully — embed URLs contain JWT tokens that expire. Don't cache beyond the token's expiry window.

Skeleton Loading State

Replace the plain loading text with a skeleton that matches the dashboard shape:

tsx

function DashboardSkeleton({ height }: { height: number }) { return ( <div style={{ height, display: "flex", flexDirection: "column", gap: 16, padding: 16 }}> {/<em class="italic"> Metric cards row </em>/} <div style={{ display: "flex", gap: 16 }}> {[1, 2, 3, 4].map((i) => ( <div key={i} style={{ flex: 1, height: 80, borderRadius: 8, background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s infinite", }} /> ))} </div> {/<em class="italic"> Chart area </em>/} <div style={{ flex: 1, borderRadius: 8, background: "linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s infinite", }} /> </div> ); }

Handling Token Refresh

For long-lived pages where the user stays for more than 10 minutes, you may want to handle the case where the embed URL expires. The simplest approach: reload the iframe with a fresh URL when visibility changes.

tsx

useEffect(() => { function handleVisibilityChange() { if (!document.hidden && iframeRef.current) { // Re-fetch embed URL when tab becomes visible again fetchEmbedUrl().then((newUrl) => { if (iframeRef.current) { iframeRef.current.src = newUrl; } }); } }

document.addEventListener("visibilitychange", handleVisibilityChange); return () => document.removeEventListener("visibilitychange", handleVisibilityChange); }, []);

---

Part 4: Environment Variables

Keep all Metabase configuration in environment variables:

bash

<h1 class="text-4xl font-bold mb-6 text-slate-900">.env.local (never committed to git)</h1> METABASE_SECRET_KEY=your-signing-secret # Server-only METABASE_SITE_URL=https://analytics.yourapp.com # Server-only

<h1 class="text-4xl font-bold mb-6 text-slate-900">.env (can be committed — these are public)</h1> NEXT_PUBLIC_METABASE_SITE_URL=https://analytics.yourapp.com # Client-side (for postMessage origin check)

<h1 class="text-4xl font-bold mb-6 text-slate-900">Dashboard IDs (public — just integers, not secrets)</h1> NEXT_PUBLIC_DASHBOARD_OVERVIEW=42 NEXT_PUBLIC_DASHBOARD_REVENUE=43

METABASE_SECRET_KEY must never be exposed to the client. In Next.js, environment variables without NEXT_PUBLIC_ prefix are server-only.

---

Part 5: TypeScript Types

typescript

// types/metabase.ts

export interface EmbedUrlResponse { embedUrl: string; }

export interface MetabasePostMessage { metabase: { type: "frame"; context: "dashboard" | "question"; height: number; pageTitle?: string; }; }

export type DashboardId = number;

export const DASHBOARD_IDS = { OVERVIEW: 42, REVENUE: 43, USAGE: 44, } as const satisfies Record<string, DashboardId>;

---

Troubleshooting React-Specific Issues

Component re-fetches the URL on every render Ensure dashboardId is stable across renders. If you're computing it inline (dashboardId={getActiveDashboardId()}), memoize it with useMemo or move it to a constant.

iframe shows a blank white box The URL was fetched but the iframe failed to load. Check browser console for CORS errors, CSP violations, or network errors. Verify METABASE_SITE_URL is accessible from the browser (not just from your server).

"Cross-Origin-Opener-Policy" error Add appropriate COOP headers to your Metabase deployment, or ensure your Next.js app isn't setting overly restrictive headers that block the iframe.

PostMessage height listener doesn't fire Verify NEXT_PUBLIC_METABASE_SITE_URL matches the exact origin (including protocol and port) of your Metabase instance. A mismatch in the origin check will silently drop all messages.

---

Summary

Embedding Metabase in a React app requires an API route that generates signed JWT URLs server-side, and a client component that fetches the URL and renders an iframe. The component pattern is straightforward: fetch on mount, handle loading and error states, and optionally use the Metabase postMessage API to auto-resize the iframe. Keep the signing secret server-only using environment variables without the NEXT_PUBLIC_ prefix. The full implementation — API route, React component, and TypeScript types — can be completed in a few hours for a basic integration.