Build Retool dashboards by combining Statistics components (for KPI numbers), Chart components (for trends), and Table components (for drill-down). Bind all components to queries that share a common date filter. Use query auto-refresh intervals for near-real-time updates. Structure your layout with Container components and a fixed 12-column grid.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 30-40 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Build a Data Dashboard with KPIs, Charts, and Filters
Retool is especially well-suited for building internal dashboards — it connects directly to your databases without an intermediate API layer, updates in real time, and requires no frontend code to lay out professional-looking charts and KPI metrics.
A typical Retool dashboard has three layers: (1) KPI numbers at the top using Statistic components, (2) trend charts in the middle using Chart components, and (3) a detailed table at the bottom for drill-down. A shared date range filter connects all three layers.
This tutorial builds a complete operations dashboard with revenue metrics, a trend line chart, and an orders table — all filtered by a shared date range picker and auto-refreshing every 60 seconds.
Prerequisites
- A Retool app connected to a SQL database with time-series data
- Basic familiarity with adding components to the Retool canvas
- A database table with at least a date column and one or more numeric metric columns
Step-by-step guide
Create the KPI aggregation query
Create a SQL query named 'getKPIs'. This query returns a single row with aggregate metrics for the selected date range. Reference the date range picker using {{ dateRangePicker1.value }} — Retool date pickers return an array [startDate, endDate]. Enable 'Run when inputs change' so the query re-runs automatically when the date range changes. Do not run on page load from this query — let the date picker's default value trigger it.
1-- getKPIs query2SELECT3 COUNT(*) AS total_orders,4 SUM(amount) AS total_revenue,5 AVG(amount) AS avg_order_value,6 COUNT(DISTINCT customer_id) AS unique_customers,7 SUM(CASE WHEN status = 'returned' THEN 1 ELSE 0 END)::float / COUNT(*) * 100 AS return_rate8FROM orders9WHERE10 created_at >= {{ dateRangePicker1.value[0] }}11 AND created_at <= {{ dateRangePicker1.value[1] }};Expected result: getKPIs returns a single aggregate row when the date range is selected.
Add a Date Range Picker and connect it to all queries
Drag a Date Range Picker component to the top of the canvas. Set its Default start date to the first day of the current month: {{ moment().startOf('month').toISOString() }}. Set its Default end date to today: {{ moment().toISOString() }}. All queries that filter by date should reference {{ dateRangePicker1.value[0] }} and {{ dateRangePicker1.value[1] }}. With 'Run when inputs change' enabled on each query, changing the date range instantly refreshes all charts and KPIs.
Expected result: Date Range Picker shows current month. Changing the date range triggers all queries to re-run.
Add KPI Statistic components at the top
Drag 4 Statistic components into a horizontal row at the top of the canvas. For each one, set the Statistic value to a field from getKPIs: {{ getKPIs.data[0].total_revenue }}, {{ getKPIs.data[0].total_orders }}, etc. Set the Label text to a human-readable name ('Total Revenue', 'Total Orders'). Use the Prefix field for currency symbols ('$') and the Suffix field for units ('%' for return rate). Set the Format to 'Number' and configure decimal places. Statistics components also support a trend delta — bind it to compare current vs prior period.
1// Statistic component values:2// Total Revenue: {{ getKPIs.data[0]?.total_revenue?.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}3// Total Orders: {{ getKPIs.data[0]?.total_orders }}4// Avg Order Value: {{ getKPIs.data[0]?.avg_order_value?.toFixed(2) }}5// Return Rate: {{ getKPIs.data[0]?.return_rate?.toFixed(1) }}Expected result: Four KPI cards display formatted metrics from the getKPIs query, updating when the date range changes.
Add a trend Chart below the KPIs
Create a second query 'getRevenueTrend' that groups revenue by day or week within the selected date range. Drag a Chart component below the KPI row. Set its Data source to {{ getRevenueTrend.data }}. Configure X axis to the date column and Y axis to the revenue column. Set the chart Type to 'Line' and enable 'Show area'. Add a title in the Inspector. The chart will automatically refresh with the date range because getRevenueTrend references the same dateRangePicker1 inputs.
1-- getRevenueTrend query2SELECT3 DATE_TRUNC('day', created_at) AS period,4 SUM(amount) AS revenue,5 COUNT(*) AS order_count6FROM orders7WHERE8 created_at >= {{ dateRangePicker1.value[0] }}9 AND created_at <= {{ dateRangePicker1.value[1] }}10GROUP BY 111ORDER BY 1;Expected result: A line chart displays daily revenue trends for the selected date range.
Add a top-items breakdown chart
Create a third query 'getTopCategories' that returns revenue by category. Add a second Chart component set to 'Bar' chart type showing category breakdowns. Use the Group By feature in the Chart Inspector to aggregate data if the query returns raw rows. For a horizontal bar chart, swap X and Y axes and set the Orientation to 'Horizontal' in the chart Inspector. This gives users a quick visual breakdown without reading a table.
Expected result: A horizontal bar chart shows revenue by category, updating with the date range filter.
Add a detail Table for drill-down
At the bottom of the dashboard, add a Table component bound to a 'getOrders' query that shows individual orders with pagination. Add a search Text Input above the table and reference it in the query: WHERE ... AND (customer_name ILIKE {{ '%' + search.value + '%' }} OR order_id::text ILIKE {{ '%' + search.value + '%' }}). Enable server-side pagination in the query using LIMIT and OFFSET tied to {{ table1.pageSize }} and {{ table1.pageIndex * table1.pageSize }}.
Expected result: A paginated, searchable orders table appears below the charts.
Configure auto-refresh for near-real-time updates
In each query's settings, scroll to the Advanced section and find 'Polling interval' or 'Auto-run'. Set all dashboard queries (getKPIs, getRevenueTrend, getTopCategories) to auto-run every 60 seconds. This keeps the dashboard fresh without requiring the user to manually refresh. For true real-time data, see the WebSocket tutorial — polling every 60 seconds covers most internal dashboard use cases at minimal query cost.
Expected result: Dashboard auto-refreshes all KPIs and charts every 60 seconds without user interaction.
Complete working example
1-- Full getKPIs query with current period + prior period comparison2-- Returns both periods for delta/trend indicators on Statistic components34WITH current_period AS (5 SELECT6 COUNT(*) AS orders,7 COALESCE(SUM(amount), 0) AS revenue,8 COALESCE(AVG(amount), 0) AS avg_order_value,9 COUNT(DISTINCT customer_id) AS unique_customers10 FROM orders11 WHERE12 created_at >= {{ dateRangePicker1.value[0] }}13 AND created_at < {{ dateRangePicker1.value[1] }}14 AND status != 'cancelled'15),16prior_period AS (17 SELECT18 COUNT(*) AS orders,19 COALESCE(SUM(amount), 0) AS revenue20 FROM orders21 WHERE22 created_at >= {{ moment(dateRangePicker1.value[0]).subtract(23 moment(dateRangePicker1.value[1]).diff(moment(dateRangePicker1.value[0]), 'days'),24 'days'25 ).toISOString() }}26 AND created_at < {{ dateRangePicker1.value[0] }}27 AND status != 'cancelled'28)29SELECT30 c.orders,31 c.revenue,32 c.avg_order_value,33 c.unique_customers,34 c.revenue - p.revenue AS revenue_delta,35 ROUND((c.revenue - p.revenue) / NULLIF(p.revenue, 0) * 100, 1) AS revenue_pct_change,36 c.orders - p.orders AS orders_delta37FROM current_period c, prior_period p;Common mistakes when creating Dashboard Applications in Retool
Why it's a problem: Building a dashboard where each KPI stat runs its own separate query, leading to 10+ queries on page load
How to avoid: Combine all KPI metrics into a single aggregation query that returns one row with all metrics. Reference individual fields as {{ getKPIs.data[0].metric_name }}.
Why it's a problem: Using dateRangePicker1.value without checking if it has been set, causing null reference errors
How to avoid: Set a default value on the Date Range Picker (current month start/end) so it always has a value when queries run on page load.
Why it's a problem: Setting auto-refresh on every query including detail tables, causing unnecessary database load
How to avoid: Only auto-refresh the top-level aggregate queries. Detail tables and drill-down queries should only refresh when explicitly triggered by user interaction.
Why it's a problem: Displaying raw numbers from queries without formatting, showing values like 1250000.456789
How to avoid: Use JavaScript number formatting in Statistic values: {{ getKPIs.data[0].revenue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }} or a transformer that pre-formats all values.
Best practices
- Use a single Date Range Picker component referenced by all queries — do not create separate date filters per query
- Always add a loading state to your KPI cards: {{ getKPIs.isFetching }} can drive a Spinner component near the title
- Format numbers in Statistic components using toLocaleString() rather than raw values — '1,250,000' is more readable than '1250000'
- Avoid loading too many queries on page load — use 'Run when inputs change' so queries only fire when the date filter has a value
- Group related charts in Container components to create visual sections and add section headings with Text components
- For public-facing dashboards, consider caching queries with a 5-minute TTL to reduce database load
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool dashboard for e-commerce operations. I need: (1) a getKPIs SQL query returning total_orders, total_revenue, avg_order_value for a date range from {{ dateRangePicker1.value[0] }} to {{ dateRangePicker1.value[1] }}, (2) four Statistic components bound to the query results with proper number formatting, (3) a line chart showing daily revenue trend from a getRevenueTrend query, (4) queries configured to auto-refresh every 60 seconds. Write the SQL queries and the JavaScript expressions for the Statistic component values.
Create a Retool dashboard with: getKPIs query (COUNT, SUM, AVG grouped aggregates with date filter), four Statistic components with formatted values {{ getKPIs.data[0].total_revenue?.toLocaleString() }}, a Chart component set to Line type bound to getRevenueTrend.data, and Date Range Picker with default start {{ moment().startOf('month').toISOString() }}. Configure all queries with 'Run when inputs change' and 60-second polling.
Frequently asked questions
Can I embed a Retool dashboard in an external website?
Yes — Retool supports embedding apps in iframes using Embed mode (Business plan and above). Generate an embed URL from the app's Share menu, configure allowed domains in Settings, and use the iframe src with an auth token. The embedded dashboard respects your Retool permissions and can accept URL parameters for pre-filtering.
How do I add drill-down functionality to a chart in a Retool dashboard?
Use the Chart component's {{ chart1.selectedPoints }} property. When a user clicks a bar or point, selectedPoints is populated with the clicked data. Configure a Temporary State variable to store the selection and use it as a filter parameter in a detail query. See the 'How to create custom charts in Retool' tutorial for the full implementation.
What is the maximum number of components I can add to a Retool dashboard before performance degrades?
There is no hard limit, but dashboards with more than 40-50 components or 15+ simultaneous queries may experience slow load times. Retool's Debug Panel Performance tab grades your app's component count. Use multipage apps to split large dashboards, and cache slow queries with a TTL to reduce database pressure.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation