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...
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.