Metabase is the open-source BI tool with embedded analytics — MetabaseProvider({ metabaseInstanceUrl, authProviderUri, theme }) wraps your app. <StaticQuestion questionId={42} /> embeds a read-only chart. <InteractiveQuestion questionId={42} /> embeds with filtering and drill-down. Server-side token: const token = jwt.sign({ resource: { question: id }, params: {}, exp: Math.floor(Date.now()/1000) + 300 }, METABASE_SECRET_KEY), then GET /api/embed/card/${token}/query. Dashboard embedding: <InteractiveDashboard dashboardId={5} initialParameters={{ date_filter: "2024" }} />. Metabase REST API: GET /api/card lists questions, POST /api/card creates a question with dataset_query, GET /api/dashboard/{id} retrieves a dashboard. Auth header: X-Metabase-Session with a session token from POST /api/session { username, password }, or API key via x-api-key. Custom themes: theme: { colors: { brand: "#6366f1" }, components: { dashboard: { card: { border: "none" } } } }. Collections: GET /api/collection tree, POST /api/collection to create folders. POST /api/card with type: "model" creates a Metabase Model (a semantic layer). Claude Code generates Metabase SDK integration, JWT embedding tokens, and API-driven dashboard creation.
CLAUDE.md for Metabase
## Metabase Stack
- SDK: @metabase/embedding-sdk-react >= 1.x
- Provider: <MetabaseProvider metabaseInstanceUrl={METABASE_URL} authProviderUri="/api/metabase-auth" theme={theme}>
- Static embed: <StaticQuestion questionId={42} height={400} />
- Interactive embed: <InteractiveQuestion questionId={42} />
- Auth endpoint: POST /api/metabase-auth → returns { token: signedJWT }
- JWT payload: { resource: { question: id }, params: {}, iat: now, exp: now+300 } signed with METABASE_SECRET_KEY
- API session: POST /api/session { username, password } → { id: sessionToken }; then X-Metabase-Session header
Metabase Auth Provider Route
// app/api/metabase-auth/route.ts — JWT token endpoint for SDK auth
import { NextResponse } from "next/server"
import jwt from "jsonwebtoken"
import { auth } from "@/lib/auth"
const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY!
const METABASE_URL = process.env.NEXT_PUBLIC_METABASE_URL!
export async function GET(req: Request) {
// Only provide tokens to authenticated users
const session = await auth()
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const token = jwt.sign(
{
email: session.user.email,
firstName: session.user.name?.split(" ")[0] ?? "",
lastName: session.user.name?.split(" ")[1] ?? "",
groups: session.user.role === "admin" ? ["Admins"] : ["Users"],
exp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 min
},
METABASE_SECRET_KEY,
)
return NextResponse.json({ token })
}
Metabase SDK Provider
// components/MetabaseProvider.tsx — SDK setup with custom theme
"use client"
import { MetabaseProvider, type MetabaseTheme } from "@metabase/embedding-sdk-react"
const analyticsTheme: MetabaseTheme = {
colors: {
brand: "#6366f1", // Indigo brand color
summarize: "#6366f1",
filter: "#6366f1",
"text-dark": "#1f2937",
"text-medium": "#6b7280",
"text-light": "#9ca3af",
background: "#ffffff",
border: "#e5e7eb",
},
fontFamily: "Inter, sans-serif",
fontSize: { question: "14px" },
components: {
dashboard: {
card: {
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "0.75rem",
boxShadow: "none",
},
},
question: {
toolbar: { display: "none" }, // Hide question toolbar in static embeds
},
filterBar: {
backgroundColor: "#f9fafb",
},
},
}
export default function MetabaseAnalyticsProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<MetabaseProvider
authProviderUri="/api/metabase-auth"
metabaseInstanceUrl={process.env.NEXT_PUBLIC_METABASE_URL!}
theme={analyticsTheme}
>
{children}
</MetabaseProvider>
)
}
Embedded Charts
// components/analytics/MetabaseChart.tsx — embedded question components
"use client"
import { StaticQuestion, InteractiveQuestion, InteractiveDashboard } from "@metabase/embedding-sdk-react"
type ChartProps = {
questionId: number
height?: number
width?: string
}
export function StaticChart({ questionId, height = 400, width = "100%" }: ChartProps) {
return (
<div style={{ width, height }} className="rounded-xl overflow-hidden border">
<StaticQuestion
questionId={questionId}
height={height}
showVisualizationSelector={false}
/>
</div>
)
}
export function InteractiveChart({ questionId, height = 500, width = "100%" }: ChartProps) {
return (
<div style={{ width, height }} className="rounded-xl overflow-hidden border">
<InteractiveQuestion
questionId={questionId}
isSaveEnabled={false}
/>
</div>
)
}
type DashboardProps = {
dashboardId: number
initialParameters?: Record<string, string | number>
className?: string
}
export function EmbeddedDashboard({ dashboardId, initialParameters, className }: DashboardProps) {
return (
<div className={`rounded-xl overflow-hidden border ${className ?? ""}`}>
<InteractiveDashboard
dashboardId={dashboardId}
initialParameters={initialParameters}
withTitle={false}
withDownloads={false}
/>
</div>
)
}
Metabase REST API Client
// lib/metabase/api.ts — Metabase REST API helper
const METABASE_URL = process.env.METABASE_URL!
const API_KEY = process.env.METABASE_API_KEY! // or session token
async function metabaseFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(`${METABASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
...((options.headers as Record<string, string>) ?? {}),
},
})
if (!res.ok) throw new Error(`Metabase API error ${res.status}: ${await res.text()}`)
return res.json()
}
export type MetabaseCard = {
id: number
name: string
description: string | null
display: string
}
export async function listQuestions(collectionId?: number): Promise<MetabaseCard[]> {
const qs = collectionId ? `?collection=${collectionId}` : ""
const { data } = await metabaseFetch<{ data: MetabaseCard[] }>(`/api/card${qs}`)
return data
}
export async function getQuestion(id: number): Promise<MetabaseCard> {
return metabaseFetch<MetabaseCard>(`/api/card/${id}`)
}
export async function getPublicEmbedToken(
questionId: number,
params: Record<string, unknown> = {},
ttlSeconds = 300,
): Promise<string> {
const jwt = (await import("jsonwebtoken")).default
return jwt.sign(
{ resource: { question: questionId }, params, exp: Math.floor(Date.now() / 1000) + ttlSeconds },
process.env.METABASE_SECRET_KEY!,
)
}
For the Grafana alternative when needing a broader observability platform with time-series data from Prometheus, InfluxDB, Loki logs, and infrastructure metrics, combined with dashboards configured via JSON — Grafana is the standard for DevOps/infrastructure dashboards while Metabase is optimized for business analysts exploring SQL databases without writing queries, see the Grafana guide. For the Retool alternative when building internal tools with drag-and-drop UI on top of any database or API — Retool focuses on building custom internal applications while Metabase focuses on BI dashboards and self-service analytics with a lower technical barrier for business users who want to explore and visualize data, see the Retool guide. The Claude Skills 360 bundle includes Metabase skill sets covering SDK embedding, JWT auth, and REST API. Start with the free tier to try embedded analytics generation.