Skip to main content
RapidDev - Software Development Agency

How to Build a KPI Dashboard with Replit

Build a KPI dashboard in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app that tracks business metrics — revenue, users, conversion rates — with sparkline trend charts, goal tracking, and optional Stripe data import. Deploy on Reserved VM if using scheduled data imports.

What you'll build

  • Configurable grid of KPI cards showing current value, target comparison with color coding (green/yellow/red), and sparkline mini-charts
  • Line and bar charts with period selectors (7d/30d/90d/1y) for each metric
  • Drag-and-drop dashboard layout editor persisting widget configuration to a JSONB column
  • PostgreSQL views for metric aggregation including period-over-period comparison and rolling averages
  • Manual data entry form for metrics without an API source
  • Optional Stripe data import pulling MRR, churn, and ARPU via the Stripe API
  • RESTful API with time-series endpoints and multi-dashboard support per user
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a KPI dashboard in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app that tracks business metrics — revenue, users, conversion rates — with sparkline trend charts, goal tracking, and optional Stripe data import. Deploy on Reserved VM if using scheduled data imports.

What you're building

Every founder checks revenue and user numbers daily. But when that data lives in three different tools — Stripe for revenue, your database for users, and Google Analytics for traffic — you spend 10 minutes every morning copy-pasting numbers into a spreadsheet. A KPI dashboard solves this by pulling everything into one view with trend indicators that immediately tell you whether you're moving in the right direction.

Replit Agent generates the Express backend and React frontend in one prompt. The data model is designed for flexibility: a `metrics` table defines what you track, `metric_values` stores the time series, and `dashboards` stores each user's layout as a JSONB array of widget configurations. This means you can add a new metric without changing any schema — just insert a row into `metrics` and start posting values.

The technical centerpiece is the PostgreSQL aggregation layer. Rather than running expensive queries on every page load, a `v_metric_summary` view pre-calculates current value, previous period value, and percentage change. A scheduled import job (set up as a Replit Scheduled Deployment) pulls Stripe data hourly and inserts into `metric_values`. The DB retry wrapper handles Replit's PostgreSQL idle sleep so the scheduled job never fails silently.

Final result

A fully functional KPI dashboard with configurable metric cards, sparkline trends, period-over-period comparison, goal tracking, and optional Stripe data import — deployed on Replit Reserved VM for reliable scheduled data pulls.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
Stripe APIData Source (optional)

Prerequisites

  • A Replit account (Free plan for development, Core or higher for deployment)
  • A list of the 5-10 KPIs you want to track (revenue, users, signups, churn, etc.)
  • Optional: Stripe API key if you want to import revenue data automatically
  • Optional: Google Analytics API credentials for user/traffic metrics

Build steps

1

Scaffold the project with Replit Agent

Create a new Repl and use the Agent prompt below to generate the full Express + PostgreSQL KPI dashboard with Drizzle schema, aggregation views, routes, and React frontend.

prompt.txt
1// Type this into Replit Agent:
2// Build a KPI dashboard with Express and PostgreSQL using Drizzle ORM.
3// Tables:
4// - metrics: id serial pk, name text not null, category text not null
5// (enum: revenue/users/engagement/performance), unit text not null
6// (enum: currency/count/percentage/duration), description text, created_at timestamp default now()
7// - metric_values: id serial pk, metric_id integer FK metrics, value numeric not null,
8// period_start timestamp not null, period_end timestamp not null,
9// source text, created_at timestamp default now()
10// - dashboards: id serial pk, user_id text not null, name text not null,
11// layout jsonb not null default '[]', created_at timestamp default now()
12// - metric_targets: id serial pk, metric_id integer FK metrics, target_value numeric not null,
13// period text not null (enum: daily/weekly/monthly/quarterly),
14// start_date timestamp, end_date timestamp
15// Create a PostgreSQL view v_metric_summary that for each metric shows:
16// current_value (latest metric_value), previous_value (second-latest), percentage_change,
17// and rolling_7d_avg.
18// Routes: GET /api/metrics, GET /api/metrics/:id/history?from=&to=,
19// POST /api/metrics/:id/values, GET /api/dashboards, POST /api/dashboards,
20// PUT /api/dashboards/:id, GET /api/import/stripe.
21// Use Replit Auth. React frontend with draggable KPI cards grid, sparkline charts
22// (recharts), period selector, and a metrics management table. Bind server to 0.0.0.0.

Pro tip: After Agent creates the schema, immediately add 5-10 rows to the metrics table using Drizzle Studio. This lets you test the dashboard layout before any real data is imported.

Expected result: A running Express app with all tables, the v_metric_summary view, and a React dashboard with empty KPI cards. The console shows 'KPI Dashboard running on port 5000'.

2

Build the metric aggregation view and history endpoint

The v_metric_summary view pre-calculates period-over-period change so the dashboard doesn't run expensive queries on every load. The history endpoint returns time-series data for the chart view.

database/views.sql
1-- Run this SQL in the Replit SQL Editor after tables are created:
2CREATE OR REPLACE VIEW v_metric_summary AS
3SELECT
4 m.id,
5 m.name,
6 m.category,
7 m.unit,
8 latest.value AS current_value,
9 prev.value AS previous_value,
10 CASE
11 WHEN prev.value = 0 OR prev.value IS NULL THEN NULL
12 ELSE ROUND(((latest.value - prev.value) / ABS(prev.value)) * 100, 2)
13 END AS percentage_change,
14 rolling.avg_7d AS rolling_7d_avg,
15 t.target_value,
16 CASE
17 WHEN t.target_value IS NULL THEN NULL
18 WHEN latest.value >= t.target_value THEN 'on_track'
19 WHEN latest.value >= t.target_value * 0.8 THEN 'at_risk'
20 ELSE 'behind'
21 END AS target_status
22FROM metrics m
23LEFT JOIN LATERAL (
24 SELECT value FROM metric_values WHERE metric_id = m.id ORDER BY period_end DESC LIMIT 1
25) latest ON true
26LEFT JOIN LATERAL (
27 SELECT value FROM metric_values WHERE metric_id = m.id ORDER BY period_end DESC LIMIT 1 OFFSET 1
28) prev ON true
29LEFT JOIN LATERAL (
30 SELECT ROUND(AVG(value), 2) AS avg_7d FROM (
31 SELECT value FROM metric_values WHERE metric_id = m.id ORDER BY period_end DESC LIMIT 7
32 ) last7
33) rolling ON true
34LEFT JOIN metric_targets t ON t.metric_id = m.id AND t.period = 'monthly';

Pro tip: The LATERAL join is PostgreSQL-specific and very efficient for this pattern — it runs one small subquery per metric row rather than joining the entire metric_values table.

Expected result: SELECT * FROM v_metric_summary returns all metrics with current_value, previous_value, percentage_change, and target_status. Metrics with no values show NULLs.

3

Build the Express API routes for metrics and dashboards

The metrics endpoint reads from the view for fast summary cards. The history endpoint accepts date range params for chart data. The dashboards endpoint saves and loads JSONB layout configurations.

server/routes/metrics.js
1const express = require('express');
2const { db } = require('../db');
3const { metrics, metricValues, dashboards } = require('../../shared/schema');
4const { eq, and, gte, lte, sql } = require('drizzle-orm');
5
6const router = express.Router();
7
8// GET /api/metrics — summary cards from view
9router.get('/metrics', async (req, res) => {
10 const summary = await db.execute(sql`SELECT * FROM v_metric_summary ORDER BY category, name`);
11 res.json(summary.rows);
12});
13
14// GET /api/metrics/:id/history?from=2024-01-01&to=2024-12-31
15router.get('/metrics/:id/history', async (req, res) => {
16 const { from, to } = req.query;
17 const conditions = [eq(metricValues.metricId, parseInt(req.params.id))];
18 if (from) conditions.push(gte(metricValues.periodEnd, new Date(from)));
19 if (to) conditions.push(lte(metricValues.periodEnd, new Date(to)));
20
21 const history = await db.select({
22 value: metricValues.value,
23 periodStart: metricValues.periodStart,
24 periodEnd: metricValues.periodEnd,
25 source: metricValues.source,
26 })
27 .from(metricValues)
28 .where(and(...conditions))
29 .orderBy(metricValues.periodEnd);
30
31 res.json(history);
32});
33
34// POST /api/metrics/:id/values — manual data entry
35router.post('/metrics/:id/values', async (req, res) => {
36 const { value, periodStart, periodEnd, source } = req.body;
37 const [inserted] = await db.insert(metricValues).values({
38 metricId: parseInt(req.params.id),
39 value,
40 periodStart: new Date(periodStart),
41 periodEnd: new Date(periodEnd),
42 source: source || 'manual',
43 }).returning();
44 res.status(201).json(inserted);
45});
46
47// PUT /api/dashboards/:id — save layout
48router.put('/dashboards/:id', async (req, res) => {
49 const [updated] = await db.update(dashboards)
50 .set({ layout: req.body.layout, name: req.body.name })
51 .where(and(eq(dashboards.id, parseInt(req.params.id)), eq(dashboards.userId, req.user.id)))
52 .returning();
53 res.json(updated);
54});
55
56module.exports = router;

Expected result: GET /api/metrics returns an array of metric summaries with current_value and percentage_change. GET /api/metrics/1/history?from=2024-01-01 returns the time series for charting.

4

Add the Stripe data import route

Store your Stripe secret key in Replit Secrets and call the Stripe API to pull MRR and subscription counts. This route can be triggered manually or called by a Scheduled Deployment every hour.

server/routes/import.js
1const Stripe = require('stripe');
2
3// GET /api/import/stripe — pull MRR and subscriber count
4router.get('/import/stripe', async (req, res) => {
5 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
6
7 try {
8 // Get active subscriptions for MRR calculation
9 const subscriptions = await stripe.subscriptions.list({ status: 'active', limit: 100 });
10
11 const mrr = subscriptions.data.reduce((sum, sub) => {
12 const monthlyAmount = sub.items.data.reduce((s, item) => {
13 const price = item.price;
14 const monthly = price.recurring.interval === 'month'
15 ? price.unit_amount * item.quantity
16 : price.unit_amount * item.quantity / 12;
17 return s + monthly;
18 }, 0);
19 return sum + monthlyAmount;
20 }, 0) / 100; // Convert cents to dollars
21
22 const subscriberCount = subscriptions.data.length;
23 const now = new Date();
24 const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); // First of month
25
26 // Find or create MRR and subscriber count metric IDs
27 // (assumes you've created these metrics manually in Drizzle Studio)
28 const mrrMetricId = parseInt(process.env.MRR_METRIC_ID);
29 const subscriberMetricId = parseInt(process.env.SUBSCRIBER_METRIC_ID);
30
31 if (mrrMetricId) {
32 await db.insert(metricValues).values({
33 metricId: mrrMetricId,
34 value: mrr,
35 periodStart,
36 periodEnd: now,
37 source: 'stripe',
38 });
39 }
40
41 if (subscriberMetricId) {
42 await db.insert(metricValues).values({
43 metricId: subscriberMetricId,
44 value: subscriberCount,
45 periodStart,
46 periodEnd: now,
47 source: 'stripe',
48 });
49 }
50
51 res.json({ mrr, subscriberCount, importedAt: now });
52 } catch (err) {
53 res.status(500).json({ error: err.message });
54 }
55});

Pro tip: Add STRIPE_SECRET_KEY and MRR_METRIC_ID to Replit Secrets (lock icon in sidebar). Never hardcode these values. Set up a Scheduled Deployment to call this endpoint every hour for always-fresh data.

5

Add the DB retry wrapper and deploy on Reserved VM

The scheduled import job needs a reliable DB connection. Replit's PostgreSQL sleeps after 5 minutes — the retry wrapper handles the wake-up. Deploy on Reserved VM if using setInterval for scheduled imports.

server/scheduler.js
1// server/db.js — connection pool with retry for idle DB
2const { drizzle } = require('drizzle-orm/node-postgres');
3const { Pool } = require('pg');
4
5const pool = new Pool({
6 connectionString: process.env.DATABASE_URL,
7 max: 10,
8 idleTimeoutMillis: 30000,
9});
10
11exports.db = drizzle(pool);
12
13// Used before scheduled jobs to ensure DB is awake
14exports.pingDb = async () => {
15 try {
16 await pool.query('SELECT 1');
17 return true;
18 } catch (err) {
19 console.log('DB ping failed, retrying in 2s...');
20 await new Promise(r => setTimeout(r, 2000));
21 await pool.query('SELECT 1');
22 return true;
23 }
24};
25
26// server/scheduler.js — hourly Stripe import (for Reserved VM)
27const { pingDb } = require('./db');
28const axios = require('axios');
29
30const INTERVAL_MS = 60 * 60 * 1000; // 1 hour
31
32async function runImport() {
33 await pingDb();
34 try {
35 await axios.get(`http://localhost:5000/api/import/stripe`);
36 console.log('Stripe import completed:', new Date().toISOString());
37 } catch (err) {
38 console.error('Stripe import failed:', err.message);
39 }
40}
41
42setInterval(runImport, INTERVAL_MS);
43runImport(); // Run once on startup

Pro tip: For Reserved VM deployment: require('./scheduler') in server/index.js to start the hourly import. For Autoscale deployment, use a separate Scheduled Deployment that calls GET /api/import/stripe via HTTP instead.

Expected result: The app runs on Reserved VM with the scheduler active. Every hour, Stripe MRR data is inserted into metric_values. The dashboard shows updated values on the next page load.

Complete code

server/routes/metrics.js
1const express = require('express');
2const { db } = require('../db');
3const { metrics, metricValues, dashboards, metricTargets } = require('../../shared/schema');
4const { eq, and, gte, lte, sql, desc } = require('drizzle-orm');
5
6const router = express.Router();
7
8// GET /api/metrics — all metrics with summary from view
9router.get('/metrics', async (req, res) => {
10 try {
11 const result = await db.execute(sql`SELECT * FROM v_metric_summary ORDER BY category, name`);
12 res.json(result.rows);
13 } catch (err) {
14 res.status(500).json({ error: err.message });
15 }
16});
17
18// GET /api/metrics/:id/history?from=ISO&to=ISO&limit=90
19router.get('/metrics/:id/history', async (req, res) => {
20 const metricId = parseInt(req.params.id);
21 const { from, to, limit = 90 } = req.query;
22 const conditions = [eq(metricValues.metricId, metricId)];
23 if (from) conditions.push(gte(metricValues.periodEnd, new Date(from)));
24 if (to) conditions.push(lte(metricValues.periodEnd, new Date(to)));
25
26 const history = await db.select()
27 .from(metricValues)
28 .where(and(...conditions))
29 .orderBy(desc(metricValues.periodEnd))
30 .limit(parseInt(limit));
31
32 res.json(history.reverse());
33});
34
35// POST /api/metrics — create a new metric definition
36router.post('/metrics', async (req, res) => {
37 const { name, category, unit, description } = req.body;
38 const [metric] = await db.insert(metrics)
39 .values({ name, category, unit, description })
40 .returning();
41 res.status(201).json(metric);
42});
43
44// POST /api/metrics/:id/values — manual data entry
45router.post('/metrics/:id/values', async (req, res) => {
46 const { value, periodStart, periodEnd, source } = req.body;
47 const [inserted] = await db.insert(metricValues).values({
48 metricId: parseInt(req.params.id),
49 value: parseFloat(value),
50 periodStart: new Date(periodStart),
51 periodEnd: new Date(periodEnd || new Date()),
52 source: source || 'manual',
53 }).returning();
54 res.status(201).json(inserted);
55});
56
57// GET /api/dashboards
58router.get('/dashboards', async (req, res) => {
59 const rows = await db.select().from(dashboards).where(eq(dashboards.userId, req.user.id));
60 res.json(rows);

Customization ideas

Google Analytics import

Add a GET /api/import/analytics route using the Google Analytics Data API. Store the GA4 property ID and service account credentials in Replit Secrets, then pull session counts, user counts, and bounce rates into metric_values.

Slack daily digest

Add a daily 9am Scheduled Deployment that reads the v_metric_summary view and sends a formatted Slack message with today's KPI snapshot using a Slack webhook URL stored in Replit Secrets.

Metric alerts

Add a metric_alerts table with threshold conditions (e.g., 'churn_rate > 5%'). The scheduled job checks all alerts after every import and sends email notifications via SendGrid when any threshold is breached.

CSV data import

Add a POST /api/metrics/:id/import/csv endpoint that accepts a CSV file with date and value columns and bulk-inserts the rows into metric_values — useful for backfilling historical data from spreadsheets.

Common pitfalls

Pitfall: Running aggregate queries on every dashboard page load instead of using the summary view

How to avoid: Read all metrics from v_metric_summary in a single query. Only fetch the full time series (from metric_values) when a user clicks on a card to open the chart view.

Pitfall: Scheduled import failing silently when the DB is idle

How to avoid: Call pingDb() at the start of every scheduled job. The ping retries the connection with a 2-second delay, ensuring the first import query succeeds after DB wake-up.

Pitfall: Using Autoscale for apps that rely on setInterval for scheduled imports

How to avoid: Use Reserved VM for the Express process if you rely on setInterval. Alternatively, use Replit's Scheduled Deployment feature to trigger the import endpoint via HTTP from a separate always-on process.

Pitfall: Saving the Stripe secret key as a hardcoded string in the import route

How to avoid: Open the Secrets panel (lock icon in the Replit sidebar), add STRIPE_SECRET_KEY, and access it with process.env.STRIPE_SECRET_KEY in your route handler.

Best practices

  • Store all API keys (Stripe, Google Analytics) in Replit Secrets (lock icon in sidebar) — never hardcode them in routes.
  • Use Drizzle Studio to manually add your metric definitions to the metrics table during initial setup, before writing a single line of import code.
  • Deploy on Reserved VM if you use setInterval for scheduled imports. Use Autoscale + a separate Scheduled Deployment for the import job if you want cost savings during low traffic.
  • Add a unique constraint on (metric_id, period_end) in metric_values to prevent duplicate imports from inserting the same data point twice.
  • Build the v_metric_summary PostgreSQL view immediately after creating the tables — it's the single source of truth for the dashboard cards and must reflect the current schema.
  • Use the pingDb() helper before every scheduled job to gracefully handle Replit's PostgreSQL idle sleep.
  • Limit the time-series chart data to 90 data points by default — more than that makes sparklines unreadable and slows the chart render.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a KPI dashboard with Express and PostgreSQL. I have a metric_values table with columns: metric_id (integer), value (numeric), period_start (timestamp), period_end (timestamp), and source (text). Help me write a PostgreSQL view called v_metric_summary that for each metric in the metrics table returns: the latest value, the second-latest value, the percentage change between them, and the rolling 7-day average. Use LATERAL joins for the sub-selects to keep the query efficient.

Build Prompt

Add a metric comparison feature to the KPI dashboard. Create a GET /api/metrics/compare?ids=1,2,3&from=2024-01-01&to=2024-12-31 route that returns time-series data for multiple metrics in a single response. On the frontend, add a Compare Mode toggle that lets users select 2-4 metrics and renders them as overlapping lines on a single recharts LineChart with a dual Y-axis for metrics with different units (e.g., currency vs count).

Frequently asked questions

Do I need to know SQL to build this dashboard?

No. Replit Agent generates all the SQL, including the v_metric_summary view. If you want to customize the aggregation logic, the Agent prompt explains what each query does in plain English. Drizzle Studio also lets you browse the results visually.

Can I track metrics that don't come from an API?

Yes. The manual data entry form lets you POST a value with a date range to any metric. This is useful for metrics you track weekly in a spreadsheet, like NPS scores or sales call counts.

What Replit plan do I need for the scheduled Stripe import?

The Free plan is sufficient for development. For the scheduled import to run reliably in production, you need a paid plan (Core or higher) and either Reserved VM ($10-20/month) or a Scheduled Deployment. Autoscale alone won't keep setInterval running because instances scale to zero.

How do I handle metrics that update at different frequencies?

Each metric_values row has its own period_start and period_end timestamps. Daily revenue updates daily, weekly NPS updates weekly — both live in the same table. The v_metric_summary view always shows the most recent value regardless of update frequency.

Can I share the dashboard with my team?

Yes. Add a shared_dashboards table with a share_token column. Generate a UUID token and store it when a user shares their dashboard. A public GET /api/dashboards/shared/:token route returns the layout without requiring authentication.

How do I prevent duplicate metric values when the import runs multiple times?

Add a unique constraint on (metric_id, period_end) in the metric_values table. When inserting, use INSERT ... ON CONFLICT (metric_id, period_end) DO UPDATE SET value = EXCLUDED.value to upsert instead of inserting a duplicate.

Can RapidDev build a custom KPI dashboard for my business?

Yes. RapidDev has built 600+ apps including analytics dashboards and business intelligence tools. They can integrate your specific data sources — CRM, ad platforms, databases — and build custom chart types. Book a free consultation at rapidevelopers.com.

Is the drag-and-drop layout saved permanently?

Yes. The dashboard layout is stored as a JSONB array in the dashboards table, where each element contains the metric_id, widget_type, and grid position. PUT /api/dashboards/:id saves the new layout after every drag event.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.