Automating Dashboard Creation with the Metabase API
Automating dashboard creation with the Metabase API means writing code that creates questions, assembles them into dashboards, configures layout, and...
Automating Dashboard Creation with the Metabase API
Automating dashboard creation with the Metabase API means writing code that creates questions, assembles them into dashboards, configures layout, and applies filters ā replacing repetitive manual UI work with repeatable, version-controlled scripts. This is valuable whenever you need to create the same dashboard structure across many customers, environments, or teams; maintain consistency at scale; or rebuild dashboards reliably from scratch.
---
When to Automate Dashboard Creation
Manual dashboard creation is fine for one-off work. Automation makes sense when:
- Multi-tenant SaaS: Each new customer needs their own dashboard or collection
---
Understanding the Data Model
Before writing automation code, understand how Metabase structures its content:
Collections (folders)
āāā Dashboards āāā Dashboard Cards (question + position) āāā Questions (cards) with dataset_query āāā Database + table + columns
To create a dashboard with questions, you need:
Field and table IDs are internal Metabase identifiers. Get them from the API:
bash
<h1 class="text-4xl font-bold mb-6 text-slate-900">Get all tables in a database</h1> GET /api/database/:id/metadata
<h1 class="text-4xl font-bold mb-6 text-slate-900">Get field IDs for a table</h1> GET /api/table/:id/query_metadata
---
Step-by-Step: Creating a Complete Dashboard
Step 1: Look Up Database and Table Metadata
javascript
const metabase = { baseUrl: process.env.METABASE_SITE_URL, headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", },
async get(path) { const res = await fetch(${this.baseUrl}${path}, { headers: this.headers }); if (!res.ok) throw new Error(GET ${path} failed: ${res.status}); return res.json(); },
async post(path, body) { const res = await fetch(${this.baseUrl}${path}, { method: "POST", headers: this.headers, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json(); throw new Error(POST ${path} failed: ${JSON.stringify(err)}); } return res.json(); },
async put(path, body) { const res = await fetch(${this.baseUrl}${path}, { method: "PUT", headers: this.headers, body: JSON.stringify(body), }); if (!res.ok) throw new Error(PUT ${path} failed: ${res.status}); return res.json(); }, };
// Get metadata for a database to find table and field IDs async function getDatabaseMetadata(databaseId) { const metadata = await metabase.get(/api/database/${databaseId}/metadata);
// Build a lookup map: tableName -> { id, fields: { fieldName -> fieldId } } const tables = {}; for (const table of metadata.tables) { tables[table.name] = { id: table.id, fields: Object.fromEntries( table.fields.map((f) => [f.name, f.id]) ), }; } return tables; }
Step 2: Create a Collection
javascript
async function createCollection(name, parentCollectionId = null) { return metabase.post("/api/collection", { name, parent_id: parentCollectionId, color: "#509EE3", // required field ā Metabase collection color }); }
Step 3: Create Questions (Cards)
Questions are the building blocks of dashboards. Each question defines a query and a visualization type.
javascript
/<em class="italic"></em> * Create a "count over time" line chart question. */ async function createCountOverTimeQuestion({ name, databaseId, tableId, dateFieldId, collectionId, temporalUnit = "week", }) { return metabase.post("/api/card", { name, display: "line", dataset_query: { database: databaseId, type: "query", query: { "source-table": tableId, aggregation: [["count"]], breakout: [ ["field", dateFieldId, { "temporal-unit": temporalUnit }], ], }, }, visualization_settings: { "graph.dimensions": ["created_at"], "graph.metrics": ["count"], }, collection_id: collectionId, }); }
/<em class="italic"></em> * Create a single metric (big number) question. */ async function createMetricQuestion({ name, databaseId, tableId, collectionId, }) { return metabase.post("/api/card", { name, display: "scalar", dataset_query: { database: databaseId, type: "query", query: { "source-table": tableId, aggregation: [["count"]], }, }, visualization_settings: {}, collection_id: collectionId, }); }
/<em class="italic"></em> * Create a native SQL question. */ async function createSqlQuestion({ name, databaseId, sql, templateTags = {}, display = "table", collectionId, }) { return metabase.post("/api/card", { name, display, dataset_query: { database: databaseId, type: "native", native: { query: sql, "template-tags": templateTags, }, }, visualization_settings: {}, collection_id: collectionId, }); }
Step 4: Create the Dashboard
javascript
async function createDashboard(name, collectionId, description = "") { return metabase.post("/api/dashboard", { name, description, collection_id: collectionId, }); }
Step 5: Add Questions to the Dashboard
Dashboard cards have a grid layout. The grid is 24 columns wide. Position and size are specified in grid units:
javascript
/<em class="italic"></em> * Add a question to a dashboard at a specific grid position. * * Grid: 24 columns wide, unlimited rows * col: 0-23 (left edge of the card) * row: 0+ (top edge of the card) * size_x: width in columns (1-24) * size_y: height in rows (typical: 4 for charts, 2 for scalar metrics) */ async function addCardToDashboard(dashboardId, cardId, { row, col, size_x, size_y }) { return metabase.post(/api/dashboard/${dashboardId}/cards, { cardId, row, col, size_x, size_y, }); }
---
Full Example: Customer Analytics Dashboard Template
This creates a complete dashboard with four cards for a new tenant:
javascript
async function createCustomerDashboard({ tenantName, databaseId, organizationId }) { // Get table and field IDs const tables = await getDatabaseMetadata(databaseId); const ordersTable = tables["orders"]; const usersTable = tables["users"];
// Create a collection for this tenant const collection = await createCollection(${tenantName} Analytics);
// Create questions const [ordersOverTime, totalRevenue, activeUsers, recentOrders] = await Promise.all([ createCountOverTimeQuestion({ name: "Orders Over Time", databaseId, tableId: ordersTable.id, dateFieldId: ordersTable.fields["created_at"], collectionId: collection.id, }),
createSqlQuestion({ name: "Total Revenue", databaseId, sql: SELECT SUM(amount) as revenue FROM orders WHERE organization_id = ${organizationId}, display: "scalar", collectionId: collection.id, }),
createMetricQuestion({ name: "Active Users", databaseId, tableId: usersTable.id, collectionId: collection.id, }),
createSqlQuestion({ name: "Recent Orders", databaseId, sql: SELECT id, customer_name, amount, created_at FROM orders WHERE organization_id = ${organizationId} ORDER BY created_at DESC LIMIT 50 , display: "table", collectionId: collection.id, }), ]);
// Create the dashboard const dashboard = await createDashboard( ${tenantName} - Analytics, collection.id, Key metrics for ${tenantName} );
// Add cards to the dashboard in a 2x2 layout // Row 0: Two metric cards (scalar) // Row 2: Line chart + Table await Promise.all([ addCardToDashboard(dashboard.id, totalRevenue.id, { row: 0, col: 0, size_x: 6, size_y: 3 }), addCardToDashboard(dashboard.id, activeUsers.id, { row: 0, col: 6, size_x: 6, size_y: 3 }), addCardToDashboard(dashboard.id, ordersOverTime.id, { row: 3, col: 0, size_x: 12, size_y: 6 }), addCardToDashboard(dashboard.id, recentOrders.id, { row: 9, col: 0, size_x: 12, size_y: 6 }), ]);
console.log(Created dashboard ${dashboard.id} for ${tenantName}); return { dashboardId: dashboard.id, collectionId: collection.id }; }
---
Adding Dashboard Filters Programmatically
Dashboard-level filters require a more complex update to the dashboard object after creation:
javascript
async function addOrganizationFilter(dashboardId, cardIds, orgFieldId) { // First, get the current dashboard to see its structure const dashboard = await metabase.get(/api/dashboard/${dashboardId});
// Define the filter const filter = { id: "org_filter", // arbitrary unique ID for this filter name: "Organization ID", slug: "organization_id", type: "id", // filter type: id, string, date, number, category default: null, sectionId: "id", };
// Connect the filter to columns in each card const parameterMappings = cardIds.map((cardId) => ({ parameter_id: filter.id, card_id: cardId, target: ["dimension", ["field", orgFieldId, null]], }));
// Update the dashboard with the filter and mappings await metabase.put(/api/dashboard/${dashboardId}, { ...dashboard, parameters: [...(dashboard.parameters || []), filter], param_fields: { ...(dashboard.param_fields || {}), [filter.id]: [ { id: orgFieldId, table_id: null, display_name: "Organization ID", semantic_type: "type/FK", }, ], }, });
// Add parameter mappings to each card for (const mapping of parameterMappings) { const currentDash = await metabase.get(/api/dashboard/${dashboardId}); const card = currentDash.dashcards.find((c) => c.card_id === mapping.card_id); if (!card) continue;
await metabase.put(/api/dashboard/${dashboardId}/cards, { cards: currentDash.dashcards.map((c) => c.card_id === mapping.card_id ? { ...c, parameter_mappings: [...(c.parameter_mappings || []), mapping] } : c ), }); } }
---
Idempotent Dashboard Creation
Production scripts should be idempotent ā safe to run multiple times without creating duplicate dashboards. Use name-based checks before creating:
javascript
async function findOrCreateCollection(name, parentId = null) { const collections = await metabase.get("/api/collection");
const existing = collections.find( (c) => c.name === name && c.parent_id === parentId ); if (existing) { console.log(Collection '${name}' already exists (id: ${existing.id})); return existing; }
return createCollection(name, parentId); }
async function findOrCreateDashboard(name, collectionId) { const collectionItems = await metabase.get( /api/collection/${collectionId}/items?models=dashboard );
const existing = collectionItems.data.find((item) => item.name === name); if (existing) { console.log(Dashboard '${name}' already exists (id: ${existing.id})); return existing; }
return createDashboard(name, collectionId); }
---
Bulk Operations: Onboarding Multiple Tenants
javascript
async function onboardTenants(tenants) { const results = []; const errors = [];
for (const tenant of tenants) { try { const result = await createCustomerDashboard({ tenantName: tenant.name, databaseId: tenant.databaseId, organizationId: tenant.id, }); results.push({ tenant: tenant.name, ...result }); console.log(ā ${tenant.name});
// Small delay between tenant provisioning to avoid overwhelming the API await new Promise((r) => setTimeout(r, 500)); } catch (err) { errors.push({ tenant: tenant.name, error: err.message }); console.error(ā ${tenant.name}: ${err.message}); } }
console.log(\nCompleted: ${results.length} succeeded, ${errors.length} failed); return { results, errors }; }
---
Summary
Automating Metabase dashboard creation through the API involves a sequence of four operations: create a collection, create questions (cards) with dataset_query objects, create a dashboard, and add cards to the dashboard with grid positions. The most important prerequisite is resolving table and field IDs from the database metadata endpoint ā these internal identifiers are required in query definitions. Make scripts idempotent by checking for existing resources before creating new ones. For multi-tenant SaaS products, this pattern scales to thousands of tenants with consistent, version-controlled dashboard templates.