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
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
| Approach | Time to Ship | Flexibility | Ongoing Cost | Best For |
|---|---|---|---|---|
| Custom charts (Recharts/Victory) | 4–12 weeks | Full control | High (maintenance) | Core product metrics, highly custom UX |
| Metabase embed | 1–3 days | Medium | Low ($500/mo) | Self-hosted, open-source preference |
| Apache Superset embed | 3–7 days | High (open-source) | Low (hosting) | Full control, complex queries |
| Looker embed | 1–2 weeks | High | High ($3,000+/mo) | Enterprise, complex data models |
| Redash embed | 2–5 days | Medium | Low (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
- Data Engineering Pipeline — ETL/ELT for analytics data
- Product Analytics — internal product analytics (PostHog/Mixpanel)
- SaaS Metrics and KPIs — what metrics to show in dashboards
- Multi-Tenancy Patterns — data isolation in analytics
- Web Development Services — analytics feature development
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours — fast.
Free consultation • No commitment • Response within 24 hours
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.