Developer Tooling & API

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

šŸ“…
šŸ“–8 min read

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
  • Standardized templates: Teams or products should share a consistent dashboard structure
  • Environment parity: Dev, staging, and production should have identical dashboard setups
  • Disaster recovery: The ability to recreate Metabase configuration from code is a form of backup
  • Dashboard factories: A single template generates hundreds of similar dashboards with minor variations
  • ---

    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:

  • The collection ID where content will live
  • The database ID for the data source
  • The table IDs for the tables being queried
  • The field IDs for the columns being used in queries
  • 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.