Build vs. Buy

Metabase vs. Embedding a Charting Library - A Technical Comparison

Choosing between Metabase and a charting library (Recharts, Chart.js, D3, Highcharts) for embedded analytics is a choice between two fundamentally dif...

šŸ“…
šŸ“–9 min read

Metabase vs. Embedding a Charting Library: A Technical Comparison

Choosing between Metabase and a charting library (Recharts, Chart.js, D3, Highcharts) for embedded analytics is a choice between two fundamentally different levels of abstraction. Charting libraries render data you provide as visual charts. Metabase handles the entire analytics stack — querying, caching, permissions, filtering, and rendering — so you only need to embed the result. This comparison gives developers the technical detail needed to make an informed choice for their specific situation.

---

The Abstraction Levels

Your Application

│ ā”œā”€ā”€ Charting Library Approach │ │ │ ā”œā”€ā”€ You build: query layer (API endpoints) │ ā”œā”€ā”€ You build: data transformation │ ā”œā”€ā”€ You build: filter state management │ ā”œā”€ā”€ You build: permission enforcement │ ā”œā”€ā”€ You build: caching │ └── Library provides: chart rendering only │ └── Metabase Approach │ ā”œā”€ā”€ Metabase handles: query execution ā”œā”€ā”€ Metabase handles: caching ā”œā”€ā”€ Metabase handles: permission enforcement ā”œā”€ā”€ Metabase handles: filter UI and state ā”œā”€ā”€ Metabase handles: chart rendering └── You provide: iframe + signed JWT token

With a charting library, you own everything above the rendering layer. With Metabase, you own only the integration surface (generating signed URLs and rendering iframes).

---

Implementation Comparison: A Revenue Dashboard

To make this concrete, compare what it takes to build the same feature — a revenue dashboard with date filtering and drill-down — using each approach.

Charting Library Implementation (React + Recharts)

Backend: API endpoints you must build and maintain

javascript

// routes/analytics.js — you write and maintain this router.get('/api/analytics/revenue', auth, async (req, res) => { const { startDate, endDate, granularity = 'week', orgId } = req.query;

// You write the SQL (and maintain it as schema changes) const query = SELECT date_trunc($1, created_at) AS period, SUM(amount) AS revenue, COUNT(*) AS order_count FROM orders WHERE created_at BETWEEN $2 AND $3 AND organization_id = $4 -- you must enforce this GROUP BY 1 ORDER BY 1 ;

const result = await db.query(query, [granularity, startDate, endDate, orgId]);

// You handle transformation res.json(result.rows.map(row => ({ period: row.period, revenue: parseFloat(row.revenue), orderCount: parseInt(row.order_count), }))); });

// You repeat this for every chart on the dashboard router.get('/api/analytics/revenue-by-product', auth, async (req, res) => { ... }); router.get('/api/analytics/top-customers', auth, async (req, res) => { ... }); router.get('/api/analytics/order-status-breakdown', auth, async (req, res) => { ... });

Frontend: Components you build and maintain

jsx

// components/RevenueDashboard.jsx — you write all of this import { useState, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';

export function RevenueDashboard({ organizationId }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dateRange, setDateRange] = useState({ start: '2024-01-01', end: '2024-12-31' }); const [granularity, setGranularity] = useState('week');

// You manage all data fetching useEffect(() => { setLoading(true); fetch(/api/analytics/revenue? + new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end, granularity, orgId: organizationId, })) .then(r => r.json()) .then(data => { setData(data); setLoading(false); }) .catch(err => { setError(err); setLoading(false); }); }, [dateRange, granularity, organizationId]);

// You build loading states if (loading) return <LoadingSkeleton />; if (error) return <ErrorState error={error} />;

return ( <div> {/<em class="italic"> You build the filter UI </em>/} <div className="filters"> <DateRangePicker value={dateRange} onChange={setDateRange} /> <GranularitySelector value={granularity} onChange={setGranularity} /> </div>

{/<em class="italic"> You configure every chart property </em>/} <ResponsiveContainer width="100%" height={300}> <LineChart data={data}> <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="period" tickFormatter={(v) => format(new Date(v), 'MMM d')} /> <YAxis tickFormatter={(v) => $${(v/1000).toFixed(0)}k} /> <Tooltip formatter={(value) => [$${value.toLocaleString()}, 'Revenue']} /> <Line type="monotone" dataKey="revenue" stroke="#2563EB" strokeWidth={2} dot={false} /> </LineChart> </ResponsiveContainer>

{/<em class="italic"> Repeat for every other chart </em>/} </div> ); }

Total for one dashboard with 4 charts:

  • ~150–200 lines of frontend code
  • ~100–150 lines of backend API code
  • ~40 lines of SQL
  • Custom date picker component (or a library dependency)
  • Loading and error state for each chart
  • No export, no drill-down, no scheduling — those are additional features to build
  • Metabase Implementation

    Backend: One endpoint, one function

    javascript
    

    // routes/analytics.js — this is the entire backend for analytics router.get('/api/analytics/embed-url', auth, async (req, res) => { const { dashboardId } = req.query; const token = jwt.sign( { resource: { dashboard: parseInt(dashboardId) }, params: { organization_id: req.user.organizationId }, exp: Math.round(Date.now() / 1000) + 600, }, process.env.METABASE_SECRET_KEY ); res.json({ embedUrl: ${process.env.METABASE_SITE_URL}/embed/dashboard/${token}#bordered=false }); });

    Frontend: One component

    jsx
    

    // components/RevenueDashboard.jsx export function RevenueDashboard() { const [embedUrl, setEmbedUrl] = useState(null);

    useEffect(() => { fetch('/api/analytics/embed-url?dashboardId=42', { credentials: 'include' }) .then(r => r.json()) .then(({ embedUrl }) => setEmbedUrl(embedUrl)); }, []);

    return embedUrl ? <iframe src={embedUrl} style={{ width: '100%', height: 600, border: 'none' }} /> : <LoadingSkeleton />; }

    Total: ~20 lines of frontend, ~15 lines of backend. The dashboard — including all charts, date filters, drill-down, export, and cross-filtering — is configured in Metabase's UI, not in code.

    ---

    Feature Comparison

    FeatureCharting LibraryMetabase
    Chart types (line, bar, pie, scatter)āœ“ (you implement)āœ“ (built-in)
    Pivot tablesBuild (significant effort)āœ“ built-in
    Date range filterBuildāœ“ built-in
    Cross-filtering (click to filter)Build (complex)āœ“ built-in
    Drill-down to underlying dataBuildāœ“ built-in
    CSV / Excel exportBuildāœ“ built-in
    Scheduled email reportsBuild + email infraāœ“ built-in
    Alerts on thresholdsBuild + job schedulerāœ“ built-in
    Row-level securityBuildāœ“ (Pro) built-in
    User-customizable queriesBuild entire query builderāœ“ built-in
    Responsive on mobileRequires careful engineeringāœ“ built-in
    CachingBuildāœ“ built-in
    Multiple chart types per dashboardBuild layout systemāœ“ built-in
    Initial implementation time4–12 weeks1–3 days
    ---

    Where Charting Libraries Win

    Pixel-perfect custom design — Charting libraries give you complete control over every visual element. If you need charts that match a specific design system exactly, or unconventional chart types (custom force-directed graphs, specialized scientific visualizations), a charting library is the right tool.

    Deep application integration — When chart interactions need to trigger actions in your application (clicking a data point opens a modal, updates a sidebar, or triggers an API call), charting libraries make this straightforward. Metabase's iframe-based embedding makes bidirectional communication with the host application more complex.

    Highly dynamic data — Real-time updating charts where data changes every second are easier to build with a charting library. Metabase is optimized for analytical (seconds-to-minutes stale) data, not real-time streaming.

    Minimal footprint — If you need one simple chart — a sparkline, a progress indicator, a single metric — a charting library is far lighter weight than deploying a full Metabase instance.

    Full offline support — Metabase requires a network connection to its server. If your application needs to work offline with locally cached data, a charting library embedded in your app bundle is the only option.

    ---

    Where Metabase Wins

    User-defined analytics — If users need to ask their own questions (filter, group, sort, change metrics), Metabase provides this through the query builder. Building an equivalent query builder is a multi-month project.

    Non-technical users — Metabase's drag-and-drop dashboard editor lets product managers and analysts create their own dashboards without engineering involvement. A charting library requires an engineer for every new view.

    Multi-chart dashboards — One Metabase dashboard replaces dozens of custom chart components, API endpoints, and the plumbing between them.

    Export and scheduling — Adding CSV export and "email me every Monday" to custom charts is a significant separate project. Metabase includes both.

    Data governance — Metabase's permission system, audit logging, and row-level security handle enterprise compliance requirements that would be significant engineering projects to build custom.

    ---

    The Hybrid Approach

    The most pragmatic choice for many teams is a hybrid: Metabase for standard dashboard and exploration use cases, custom charting for the specific views where design control or application integration is critical.

    Application layout
    

    ā”œā”€ā”€ Standard analytics section (embedded Metabase dashboard) │ └── Revenue, users, retention, standard KPIs ā”œā”€ā”€ Custom specialized chart (Recharts / D3) │ └── Interactive funnel that triggers your app's onboarding flow └── Real-time monitoring widget (custom) └── Live order queue with WebSocket updates

    This lets the team ship the 80% of analytics that's dashboard-shaped using Metabase, reserving engineering effort for the 20% where custom implementation is genuinely necessary.

    ---

    Decision Factors Summary

    Choose a charting library when:

  • You need full pixel-level design control over charts
  • Charts must deeply integrate with application state (click triggers app action)
  • Data updates in real time (< 5 second refresh)
  • You need a single simple chart with minimal infrastructure
  • Offline functionality is required
  • Choose Metabase when:

  • You need a multi-chart dashboard with filtering
  • Users need to customize what they see
  • You need export, scheduling, or alerting
  • Row-level security is required
  • You need to ship quickly
  • Non-technical users will create or modify dashboards
  • Compliance requirements need audit logging and access control
  • Choose both when:

  • Standard analytics goes in Metabase
  • Specific highly-custom or real-time views are built custom
  • You want to reduce engineering scope without sacrificing design quality where it matters most
  • ---

    Summary

    Charting libraries (Recharts, Chart.js, D3) render charts — they don't query data, manage permissions, cache results, or handle filtering. Metabase handles the entire analytics stack; embedding it requires generating a signed URL and rendering an iframe. For a four-chart dashboard with date filtering, the charting library approach requires ~300 lines of code plus ongoing maintenance; the Metabase approach requires ~35 lines of code. Charting libraries win when pixel-perfect design control, deep application integration, or real-time data is required. Metabase wins for everything dashboard-shaped: multi-chart views, user-customizable queries, export and scheduling, and enterprise access control. The hybrid approach — Metabase for standard analytics, custom charts for specialized views — is the most practical choice for most teams.