Embedding Analytics

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

📅
📖9 min read

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 deployment
  • A dashboard created in Metabase with at least one question
  • A web application with a server-side component (Node.js, Python, Ruby, Go, etc.)
  • If 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

  • Open the dashboard you want to embed
  • Click Sharing (the share icon in the top right)
  • Click Embed this dashboard in an application
  • You'll see two sections: Parameters and Look and Feel
  • Configure Parameters

    Parameters are the filters you've added to your dashboard. For each parameter, you can set it to:

  • Locked — the value is set by your server and cannot be changed by the user. Use this for security-critical filters like company_id or user_id.
  • Enabled — the filter is visible and the user can change it. Use this for things like date ranges or optional filters.
  • Disabled — the filter is hidden entirely.
  • 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:

  • URL: Open the dashboard in Metabase. The URL is /dashboard/42 — the number is the ID.
  • API: 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.