Back to Blog

Embedded Analytics: Metabase, Superset, Looker Embed, and Customer-Facing Dashboards

Build embedded analytics for SaaS products — Metabase embedding, Apache Superset setup, Looker embedded dashboards, custom chart libraries, row-level security f

Viprasol Tech Team
May 29, 2026
12 min read

Embedded Analytics: Metabase, Superset, Looker Embed, and Customer-Facing Dashboards

Embedded analytics lets your customers see insights about their own data within your product, without leaving your application. For SaaS products, this is often the difference between "reporting is on the roadmap" and "customers have a real-time analytics dashboard they love."

Build vs embed is the core decision. Building custom charts takes significant ongoing engineering time. Embedding a BI tool takes days but comes with constraints.


Build vs Embed Decision

ApproachTime to ShipFlexibilityOngoing CostBest For
Custom charts (Recharts/Victory)4–12 weeksFull controlHigh (maintenance)Core product metrics, highly custom UX
Metabase embed1–3 daysMediumLow ($500/mo)Self-hosted, open-source preference
Apache Superset embed3–7 daysHigh (open-source)Low (hosting)Full control, complex queries
Looker embed1–2 weeksHighHigh ($3,000+/mo)Enterprise, complex data models
Redash embed2–5 daysMediumLow (open-source)Simple dashboards, SQL-first teams

The hybrid approach (most common):

  • Core product metrics: custom-built (users care about these deeply; UX must be perfect)
  • Advanced analytics / custom reports: embedded BI tool (users who want this accept generic UX)

Metabase: Fastest to Production

Metabase's embedding feature lets you embed any dashboard or question (chart) in your application with a signed JWT for security.

Self-hosted Docker setup:

# docker-compose.yml
services:
  metabase:
    image: metabase/metabase:latest
    ports:
      - "3100:3000"
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabase
      MB_DB_PORT: 5432
      MB_DB_USER: metabase
      MB_DB_PASS: ${METABASE_DB_PASSWORD}
      MB_DB_HOST: postgres
      MB_EMBEDDING_SECRET_KEY: ${METABASE_SECRET_KEY}  # For JWT signing
      MB_EMBEDDING_APP_ORIGIN: "https://app.yourproduct.com"  # Allow embedding from your domain
    depends_on:
      - postgres

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${METABASE_DB_PASSWORD}
    volumes:
      - metabase_postgres:/var/lib/postgresql/data

volumes:
  metabase_postgres:

Generate signed embed URL (server-side):

// lib/metabase.ts
import jwt from 'jsonwebtoken';

const METABASE_SECRET_KEY = process.env.METABASE_SECRET_KEY!;
const METABASE_URL = process.env.METABASE_URL ?? 'http://localhost:3100';

interface EmbedOptions {
  resourceType: 'dashboard' | 'question';
  resourceId: number;
  params: Record<string, string | number | boolean>;
  expiresInSeconds?: number;
}

export function generateMetabaseEmbedUrl(options: EmbedOptions): string {
  const payload = {
    resource: { [options.resourceType]: options.resourceId },
    params: options.params,
    // Lock params so users can't override them (tenant isolation)
    exp: Math.round(Date.now() / 1000) + (options.expiresInSeconds ?? 600),
  };

  const token = jwt.sign(payload, METABASE_SECRET_KEY);
  return `${METABASE_URL}/embed/${options.resourceType}/${token}#bordered=false&titled=false`;
}

// API endpoint: serve embed URL to authenticated users
app.get('/api/analytics/dashboard-url', async (request, reply) => {
  const { tenantId } = request;

  const embedUrl = generateMetabaseEmbedUrl({
    resourceType: 'dashboard',
    resourceId: 12,  // Dashboard ID in Metabase
    params: {
      tenant_id: tenantId,  // Filter dashboard data to this tenant
    },
    expiresInSeconds: 3600,
  });

  return reply.send({ url: embedUrl });
});

Frontend embedding:

// components/AnalyticsDashboard.tsx
import { useEffect, useState } from 'react';

export function AnalyticsDashboard() {
  const [embedUrl, setEmbedUrl] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/analytics/dashboard-url')
      .then(r => r.json())
      .then(data => setEmbedUrl(data.url));
  }, []);

  if (!embedUrl) {
    return <div className="animate-pulse bg-gray-200 rounded-lg h-96" />;
  }

  return (
    <div className="rounded-lg overflow-hidden border border-gray-200">
      <iframe
        src={embedUrl}
        className="w-full"
        style={{ height: '600px', border: 'none' }}
        title="Analytics Dashboard"
        // Security: sandbox restricts iframe capabilities
        sandbox="allow-scripts allow-same-origin allow-popups"
      />
    </div>
  );
}

Multi-tenant isolation: Always pass tenant_id as a locked parameter. Metabase's signed token prevents users from changing the parameter value — the token signature would break.


🚀 SaaS MVP in 8 Weeks — Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.

  • Week 1–2: Architecture design + wireframes
  • Week 3–6: Core features built + tested
  • Week 7–8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Apache Superset: Open-Source, Full Control

Superset is more complex to set up but gives full control and has no per-seat licensing cost.

# docker-compose.yml for Superset
services:
  superset:
    image: apache/superset:latest
    ports:
      - "8088:8088"
    environment:
      SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
      DATABASE_URL: postgresql://superset:${DB_PASS}@postgres:5432/superset
    command: >
      bash -c "
        superset db upgrade &&
        superset fab create-admin --username admin --firstname Admin --lastname User --email admin@example.com --password admin &&
        superset init &&
        superset run -h 0.0.0.0 -p 8088 --with-threads"

Row-Level Security (RLS) in Superset:

Superset's RLS filters add WHERE clauses to queries based on the current user's attributes — enforcing tenant isolation at the database query level.

# Create RLS filter via Superset API
import requests

session = requests.Session()
# Login first to get access token
login_resp = session.post('http://localhost:8088/api/v1/security/login', json={
    'username': 'admin', 'password': 'admin',
    'provider': 'db', 'refresh': True,
})
access_token = login_resp.json()['access_token']

headers = {'Authorization': f'Bearer {access_token}'}

# Create RLS filter: when user has tenant_id attribute, filter all queries
rls_resp = session.post('http://localhost:8088/api/v1/rowlevelsecurity/', headers=headers, json={
    'name': 'tenant_isolation',
    'filter_type': 'Regular',
    'tables': [{'id': 42}],  # Table IDs to apply filter to
    'roles': [{'id': 1}],    # Roles this filter applies to
    'clause': 'tenant_id = {{ current_user_attribute("tenant_id") }}',
    'group_key': 'tenant_id',
})

Custom Charts with Recharts

For core product metrics, custom charts give you full control over UX, theming, and interactions:

// components/RevenueChart.tsx
import {
  AreaChart, Area, XAxis, YAxis, CartesianGrid,
  Tooltip, ResponsiveContainer, Legend
} from 'recharts';

interface RevenueDataPoint {
  date: string;
  mrr: number;
  expansion: number;
  churn: number;
}

const CustomTooltip = ({ active, payload, label }: any) => {
  if (!active || !payload?.length) return null;

  return (
    <div className="bg-white border border-gray-200 rounded-lg shadow-lg p-3">
      <p className="font-medium text-gray-900 mb-2">{label}</p>
      {payload.map((entry: any) => (
        <p key={entry.name} className="text-sm" style={{ color: entry.color }}>
          {entry.name}: ${(entry.value / 100).toLocaleString()}
        </p>
      ))}
    </div>
  );
};

export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
  return (
    <div className="bg-white rounded-lg border p-6">
      <h2 className="text-lg font-semibold text-gray-900 mb-4">MRR Breakdown</h2>
      <ResponsiveContainer width="100%" height={300}>
        <AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
          <XAxis
            dataKey="date"
            tick={{ fontSize: 12, fill: '#6b7280' }}
            tickFormatter={v => new Date(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
          />
          <YAxis
            tick={{ fontSize: 12, fill: '#6b7280' }}
            tickFormatter={v => `$${(v / 100 / 1000).toFixed(0)}k`}
          />
          <Tooltip content={<CustomTooltip />} />
          <Legend />
          <Area
            type="monotone" dataKey="mrr"
            name="New MRR" stroke="#3b82f6" fill="#eff6ff"
            stackId="1"
          />
          <Area
            type="monotone" dataKey="expansion"
            name="Expansion" stroke="#10b981" fill="#ecfdf5"
            stackId="1"
          />
          <Area
            type="monotone" dataKey="churn"
            name="Churn" stroke="#ef4444" fill="#fef2f2"
            stackId="1"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

💡 The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity — you own everything

Analytics Data Architecture

The analytics stack needs its own data layer — don't run complex analytical queries against your production OLTP database:

-- Analytics schema: pre-aggregated daily snapshots
-- Populated nightly by ETL job

CREATE TABLE mrr_daily (
    date            DATE NOT NULL,
    tenant_id       UUID NOT NULL,
    new_mrr_cents   INTEGER NOT NULL DEFAULT 0,
    expansion_cents INTEGER NOT NULL DEFAULT 0,
    contraction_cents INTEGER NOT NULL DEFAULT 0,
    churned_cents   INTEGER NOT NULL DEFAULT 0,
    ending_mrr_cents INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (date, tenant_id)
);

CREATE TABLE events_daily (
    date            DATE NOT NULL,
    tenant_id       UUID NOT NULL,
    event_type      TEXT NOT NULL,
    event_count     INTEGER NOT NULL DEFAULT 0,
    unique_users    INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (date, tenant_id, event_type)
);

-- Fast tenant-scoped queries
CREATE INDEX idx_mrr_daily_tenant ON mrr_daily(tenant_id, date DESC);
CREATE INDEX idx_events_daily_tenant ON events_daily(tenant_id, date DESC, event_type);

Working With Viprasol

We build embedded analytics for SaaS products — Metabase or Superset setup with multi-tenant isolation, custom Recharts dashboards for core metrics, data pipeline setup for pre-aggregated analytics, and the API layer that serves charts to authenticated users.

Talk to our team about analytics infrastructure for your product.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours — fast.

Free consultation • No commitment • Response within 24 hours

Viprasol · AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.