How to Embed a Metabase Dashboard in Your Web App
Embedding a Metabase dashboard in a web application requires three things: a Metabase instance with embedding enabled, a server-side function that gen...
How to Embed a Metabase Dashboard in Your Web App
Embedding a Metabase dashboard in a web application requires three things: a Metabase instance with embedding enabled, a server-side function that generates a signed JWT URL, and an iframe in your frontend that points to that URL. This guide walks through the complete implementation from scratch, including setup, code for multiple backend languages, React and vanilla JS frontend examples, and solutions to the most common problems.
---
Prerequisites
Before you start:
- Metabase Pro or Enterprise (signed embedding requires a paid plan)
MB_EMBEDDING_SECRET_KEY configured in your Metabase deploymentIf you're on the open-source edition and just want to try embedding, public embedding works without authentication — skip to the Public Embedding section below.
---
Part 1: Configure Metabase for Embedding
Enable Embedding Globally
In Metabase, navigate to Admin → Embedding and enable the embedding feature. This is a one-time global switch.
Enable Embedding on Your Dashboard
Configure Parameters
Parameters are the filters you've added to your dashboard. For each parameter, you can set it to:
company_id or user_id.Set any filters that restrict which data a user can see to Locked. This is what enforces data isolation in multi-tenant deployments.
Publish the Dashboard
Click Publish to make the dashboard available for embedding. Unpublished dashboards cannot be embedded even with a valid token.
---
Part 2: Server-Side Token Generation
The JWT token is generated on your server, signed with the shared secret, and sent to the browser as part of the embed URL. The browser renders the iframe — it never sees or handles the secret.
Node.js
bash
npm install jsonwebtoken
javascript
// embedUtils.js const jwt = require("jsonwebtoken");
const METABASE_SITE_URL = process.env.METABASE_SITE_URL; const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY;
/<em class="italic"></em> * Generate a signed Metabase embed URL for a dashboard. * * @param {number} dashboardId - The Metabase dashboard ID * @param {object} lockedParams - Parameters to lock (user cannot change) * @param {object} options - UI options (bordered, titled, theme) * @returns {string} The signed embed URL */ function getDashboardEmbedUrl(dashboardId, lockedParams = {}, options = {}) { const { bordered = true, titled = true, theme = null, } = options;
const payload = { resource: { dashboard: dashboardId }, params: lockedParams, exp: Math.round(Date.now() / 1000) + (10 * 60), // expires in 10 minutes };
const token = jwt.sign(payload, METABASE_SECRET_KEY);
const hashParams = new URLSearchParams(); if (bordered !== undefined) hashParams.set("bordered", bordered); if (titled !== undefined) hashParams.set("titled", titled); if (theme) hashParams.set("theme", theme);
return ${METABASE_SITE_URL}/embed/dashboard/${token}#${hashParams.toString()}; }
/<em class="italic"></em> * Generate a signed Metabase embed URL for a single question/chart. * * @param {number} questionId - The Metabase question ID * @param {object} lockedParams - Parameters to lock * @returns {string} The signed embed URL */ function getQuestionEmbedUrl(questionId, lockedParams = {}) { const payload = { resource: { question: questionId }, params: lockedParams, exp: Math.round(Date.now() / 1000) + (10 * 60), };
const token = jwt.sign(payload, METABASE_SECRET_KEY); return ${METABASE_SITE_URL}/embed/question/${token}; }
module.exports = { getDashboardEmbedUrl, getQuestionEmbedUrl };
Express.js route example:
javascript
// routes/analytics.js const express = require("express"); const { getDashboardEmbedUrl } = require("../embedUtils"); const { requireAuth } = require("../middleware/auth");
const router = express.Router();
router.get("/embed-url", requireAuth, (req, res) => { const { user } = req;
// Generate embed URL with locked params based on authenticated user const embedUrl = getDashboardEmbedUrl( process.env.MAIN_DASHBOARD_ID, { organization_id: user.organizationId, // Add other locked params as needed }, { bordered: false, titled: false } );
res.json({ embedUrl }); });
module.exports = router;
Python (Flask)
bash
pip install PyJWT flask
python
<h1 class="text-4xl font-bold mb-6 text-slate-900">embed_utils.py</h1> import jwt import time import os from urllib.parse import urlencode
METABASE_SITE_URL = os.environ["METABASE_SITE_URL"] METABASE_SECRET_KEY = os.environ["METABASE_SECRET_KEY"]
def get_dashboard_embed_url( dashboard_id: int, locked_params: dict = None, bordered: bool = True, titled: bool = True, theme: str = None ) -> str: if locked_params is None: locked_params = {}
payload = { "resource": {"dashboard": dashboard_id}, "params": locked_params, "exp": round(time.time()) + (10 * 60) # 10 minute expiry }
token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256")
hash_params = { "bordered": str(bordered).lower(), "titled": str(titled).lower(), } if theme: hash_params["theme"] = theme
return f"{METABASE_SITE_URL}/embed/dashboard/{token}#{urlencode(hash_params)}"
def get_question_embed_url(question_id: int, locked_params: dict = None) -> str: if locked_params is None: locked_params = {}
payload = { "resource": {"question": question_id}, "params": locked_params, "exp": round(time.time()) + (10 * 60) }
token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256") return f"{METABASE_SITE_URL}/embed/question/{token}"
Flask route:
python
<h1 class="text-4xl font-bold mb-6 text-slate-900">routes/analytics.py</h1> from flask import Blueprint, jsonify, g from embed_utils import get_dashboard_embed_url from middleware.auth import require_auth import os
analytics_bp = Blueprint("analytics", __name__)
@analytics_bp.route("/embed-url") @require_auth def get_embed_url(): user = g.current_user
embed_url = get_dashboard_embed_url( dashboard_id=int(os.environ["MAIN_DASHBOARD_ID"]), locked_params={ "organization_id": user.organization_id }, bordered=False, titled=False )
return jsonify({"embedUrl": embed_url})
Ruby on Rails
ruby
<h1 class="text-4xl font-bold mb-6 text-slate-900">Gemfile</h1> gem "jwt"
ruby
<h1 class="text-4xl font-bold mb-6 text-slate-900">app/lib/metabase_embed.rb</h1> require "jwt"
module MetabaseEmbed SITE_URL = ENV.fetch("METABASE_SITE_URL") SECRET_KEY = ENV.fetch("METABASE_SECRET_KEY")
def self.dashboard_url(dashboard_id, locked_params: {}, bordered: true, titled: true, theme: nil) payload = { resource: { dashboard: dashboard_id }, params: locked_params, exp: Time.now.to_i + 600 # 10 minutes }
token = JWT.encode(payload, SECRET_KEY, "HS256")
hash_params = "bordered=#{bordered}&titled=#{titled}" hash_params += "&theme=#{theme}" if theme
"#{SITE_URL}/embed/dashboard/#{token}\##{hash_params}" end end
---
Part 3: Frontend Implementation
React Component
jsx
// components/MetabaseDashboard.jsx import { useState, useEffect } from "react";
export function MetabaseDashboard({ height = 600, className = "" }) { const [embedUrl, setEmbedUrl] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true);
useEffect(() => { fetch("/api/analytics/embed-url", { credentials: "include", // include auth cookies }) .then((res) => { if (!res.ok) throw new Error("Failed to fetch embed URL"); return res.json(); }) .then((data) => { setEmbedUrl(data.embedUrl); setLoading(false); }) .catch((err) => { setError(err.message); setLoading(false); }); }, []);
if (loading) return <div className="analytics-loading">Loading analytics...</div>; if (error) return <div className="analytics-error">Analytics unavailable</div>;
return ( <iframe src={embedUrl} style={{ width: "100%", height, border: "none" }} allowTransparency="true" title="Analytics Dashboard" /> ); }
Usage:
jsx
// pages/AnalyticsPage.jsx import { MetabaseDashboard } from "../components/MetabaseDashboard";
export function AnalyticsPage() { return ( <div className="page"> <h1>Your Analytics</h1> <MetabaseDashboard height={700} /> </div> ); }
Vanilla JavaScript
html
<!-- analytics.html --> <div id="analytics-container"> <div id="analytics-loading">Loading...</div> <iframe id="analytics-frame" style="width: 100%; height: 600px; border: none; display: none;" allowtransparency="true" title="Analytics Dashboard" ></iframe> </div>
<script> async function loadDashboard() { const loading = document.getElementById("analytics-loading"); const frame = document.getElementById("analytics-frame");
try { const res = await fetch("/api/analytics/embed-url", { credentials: "include", }); const { embedUrl } = await res.json();
frame.src = embedUrl; frame.onload = () => { loading.style.display = "none"; frame.style.display = "block"; }; } catch (err) { loading.textContent = "Analytics unavailable."; } }
loadDashboard(); </script>
---
Part 4: Handling iframe Height
A common pain point with embedded dashboards is getting the iframe height right. Metabase dashboards have variable height depending on the number of cards and layout. There are two approaches:
Fixed Height
Set a fixed height and let the dashboard scroll internally:
html
<iframe src={embedUrl} style="width: 100%; height: 800px; border: none;" />
Simple but may clip content or waste space.
Dynamic Height with PostMessage
Metabase emits a postMessage event with the dashboard's height when it finishes loading. You can use this to resize the iframe dynamically:
javascript
window.addEventListener("message", (event) => { // Verify the origin matches your Metabase URL if (event.origin !== process.env.METABASE_SITE_URL) return;
const { metabase } = event.data; if (metabase?.type === "frame" && metabase?.context === "dashboard") { const iframe = document.getElementById("analytics-frame"); if (iframe) { iframe.style.height = ${metabase.height}px; } } });
React hook version:
jsx
function useMetabaseHeight(iframeRef) { useEffect(() => { const handler = (event) => { if (event.origin !== process.env.NEXT_PUBLIC_METABASE_SITE_URL) return; const { metabase } = event.data || {}; if (metabase?.type === "frame" && iframeRef.current) { iframeRef.current.style.height = ${metabase.height}px; } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [iframeRef]); }
---
Part 5: Finding Dashboard and Question IDs
To generate an embed URL, you need the numeric ID of the dashboard or question. Find it by:
/dashboard/42 — the number is the ID.GET /api/dashboard returns a list of all dashboards with their IDs.Store these IDs in environment variables in your host application rather than hardcoding them.
---
Troubleshooting
"Embedding is not enabled" Embedding hasn't been enabled globally in Metabase Admin, or embedding hasn't been enabled on the specific dashboard.
"Invalid token" The JWT is malformed, signed with the wrong secret, or the algorithm doesn't match. Verify the secret matches MB_EMBEDDING_SECRET_KEY exactly.
"Token is expired" The exp claim in the JWT has passed. Tokens are generated per page load — ensure your server generates a fresh token each time the user loads the analytics page.
"Parameter X is locked but not provided" A parameter marked as Locked in the dashboard's embedding settings must be provided in the JWT payload. Either provide the value or change the parameter to Enabled.
Dashboard appears but shows no data The locked parameter value doesn't match any data. For example, company_id: 99 returns no rows because there's no data for that company. Check the parameter values being passed.
iframe is too short or too tall Use the postMessage approach for dynamic height, or set a fixed height that fits your longest expected dashboard.
CSP (Content Security Policy) errors in browser console Your host app's CSP headers must allow the Metabase domain as a frame source:
Content-Security-Policy: frame-src https://your-metabase-domain.com;
---
Summary
Embedding a Metabase dashboard in a web app requires generating a signed JWT token on your server (never in the browser), rendering an iframe with the resulting URL, and configuring Metabase to publish the dashboard for embedding with the right parameters locked. The server-side token generation is the same pattern in any language — construct a payload with the resource and locked params, sign it with the shared secret, and append UI options as URL hash parameters. The entire flow can be implemented in under a day for a basic integration.