Building a Real-Time Operations Dashboard with Convex and Next.js
redxtrm
Full-stack developer and business consultant specializing in Next.js, React, and e-commerce solutions.

# Building a Real-Time Operations Dashboard with Convex and Next.js
Most business dashboards lie to you. They show data from the last time someone hit refresh. In manufacturing, where an order status can change three times in an hour, stale data isn't just annoying — it causes mistakes, duplicate work, and missed deadlines.
We needed a dashboard that shows what's happening right now. Not five minutes ago. Not after a page reload. Now.
This is how we built it with Convex and Next.js, and why this stack changed how we think about real-time applications.
The Problem with Traditional Dashboards
Our operations team was managing order tracking through a combination of spreadsheets, WhatsApp messages, and a basic admin panel that required manual refreshing. The workflow looked like this:
1. Customer places an order through the website 2. Someone manually enters it into a spreadsheet 3. Production manager checks the spreadsheet (maybe) 4. Status updates happen via WhatsApp group messages 5. Admin panel shows order status — if someone remembered to update it 6. Customer asks for an update, someone scrambles to find the latest info
The gap between reality and what the dashboard showed was anywhere from 30 minutes to a full day. For a business processing 50-100 orders per week across multiple production stages, this was unsustainable.
- We needed:
- **Instant updates** — when an order status changes, every screen showing that order updates immediately
- **Multi-user awareness** — see who's viewing what, who changed what, when
- **Offline resilience** — the factory floor has spotty WiFi
- **Zero polling** — no `setInterval(fetchData, 5000)` hammering the server
Why Convex (and Not Firebase, Supabase, or WebSockets)
We evaluated several options:
Firebase Realtime Database Firebase was the obvious first choice. We've used it before. But Firebase's data model is a giant JSON tree, and querying across collections requires denormalization that becomes a maintenance nightmare. Also, their pricing model charges per document read — a real-time dashboard that 10 people keep open all day generates a lot of reads.
Supabase Realtime Supabase gives you PostgreSQL with real-time subscriptions via WebSocket channels. It's solid, and we use Supabase for other projects. But the real-time layer is an add-on to a fundamentally request-response database. You subscribe to table changes, but complex queries (joins across tables, computed fields) don't stream natively. You end up building a caching layer on top.
Raw WebSockets We could roll our own WebSocket server. Full control, no vendor lock-in. But managing connection state, reconnection logic, message ordering, conflict resolution, and scaling across multiple server instances is a project in itself. We wanted to build a dashboard, not a real-time infrastructure company.
Convex Convex takes a different approach. Instead of subscribing to table changes, you subscribe to **query results**. You write a server function that computes the data you need, and Convex automatically tracks which database rows that function depends on. When any of those rows change, Convex re-runs the function and pushes the new result to every connected client.
// This query automatically updates on every connected client
// whenever any order in the "orders" table changes
import { query } from './_generated/server'export const getActiveOrders = query({ args: { status: v.optional(v.string()) }, handler: async (ctx, args) => { let orders = await ctx.db .query('orders') .withIndex('by_status') .filter((q) => args.status ? q.eq(q.field('status'), args.status) : q.neq(q.field('status'), 'completed') ) .order('desc') .collect()
// Enrich with customer data — Convex tracks these reads too
return Promise.all(
orders.map(async (order) => {
const customer = await ctx.db.get(order.customerId)
return {
...order,
customerName: customer?.name ?? 'Unknown',
customerEmail: customer?.email,
}
})
)
},
})
`
On the client side:
import { useQuery } from 'convex/react' import { api } from '@/convex/_generated/api'
export function ActiveOrders() { const orders = useQuery(api.orders.getActiveOrders, {})
if (!orders) return
return (
{orders.map((order) => (
))}
)
}
`
That's it. No WebSocket setup. No subscription management. No manual cache invalidation. When someone updates an order anywhere in the system, every dashboard showing that order re-renders with the new data in under 100ms.
This is what sold us.
Architecture Overview
The final architecture has three layers:
1. Convex Backend (Reactive Data Layer)
All business logic lives in Convex server functions:
- **Queries** — read data, automatically reactive
- **Mutations** — write data, trigger query re-evaluation
- **Actions** — side effects (sending emails, calling external APIs)
- **Scheduled functions** — cron-like jobs for recurring tasks
Schema design:
import { defineSchema, defineTable } from 'convex/server'export default defineSchema({ orders: defineTable({ orderNumber: v.string(), customerId: v.id('customers'), status: v.union( v.literal('pending'), v.literal('confirmed'), v.literal('in_production'), v.literal('quality_check'), v.literal('packaging'), v.literal('shipped'), v.literal('completed') ), items: v.array( v.object({ productId: v.string(), quantity: v.number(), unitPrice: v.number(), specifications: v.any(), }) ), totalAmount: v.number(), createdAt: v.number(), updatedAt: v.number(), assignedTo: v.optional(v.id('users')), notes: v.array( v.object({ text: v.string(), author: v.id('users'), timestamp: v.number(), }) ), }) .index('by_status', ['status']) .index('by_customer', ['customerId']) .index('by_date', ['createdAt']),
customers: defineTable({ name: v.string(), email: v.string(), phone: v.optional(v.string()), company: v.optional(v.string()), totalOrders: v.number(), totalSpent: v.number(), }).index('by_email', ['email']),
activity_log: defineTable({
userId: v.id('users'),
action: v.string(),
entityType: v.string(),
entityId: v.string(),
details: v.optional(v.any()),
timestamp: v.number(),
})
.index('by_timestamp', ['timestamp'])
.index('by_entity', ['entityType', 'entityId']),
})
`
2. Next.js Frontend (App Router)
The dashboard is a Next.js application using the App Router. Key architectural decisions:
Server Components for layout, Client Components for real-time data:
// app/dashboard/layout.tsx — Server Component
// Handles auth check, renders shellexport default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return {children}
}
`
// app/dashboard/page.tsx — Server Component wrapperexport default function DashboardPage() {
return
}
`
// components/dashboard/content.tsx — Client Componentimport { useQuery } from 'convex/react' import { api } from '@/convex/_generated/api' import { StatsGrid } from './stats-grid' import { OrdersTable } from './orders-table' import { ActivityFeed } from './activity-feed' import { ProductionTimeline } from './production-timeline'
export function DashboardContent() { const stats = useQuery(api.dashboard.getStats) const recentActivity = useQuery(api.activity.getRecent, { limit: 20 })
return (
)
}
`
ConvexProvider wraps the entire app:
// app/providers.tsximport { ConvexProvider, ConvexReactClient } from 'convex/react'
const convex = new ConvexReactClient( process.env.NEXT_PUBLIC_CONVEX_URL! )
export function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
`
3. Integration Layer (External Systems)
Convex actions handle external integrations:
import { action } from './_generated/server'
import { internal } from './_generated/api'export const syncOrderStatus = action({ args: { orderId: v.id('orders'), newStatus: v.string(), }, handler: async (ctx, args) => { // Update order in Convex await ctx.runMutation(internal.orders.updateStatus, { orderId: args.orderId, status: args.newStatus, })
// Log the activity await ctx.runMutation(internal.activity.log, { action: 'status_changed', entityType: 'order', entityId: args.orderId, details: { newStatus: args.newStatus }, })
// Send notification via external service const order = await ctx.runQuery(internal.orders.getById, { id: args.orderId, })
if (order?.customerEmail) {
await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: \Bearer \${process.env.SENDGRID_API_KEY}\,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: order.customerEmail,
subject: \Order #\${order.orderNumber} — Status Update\,
text: \Your order status has been updated to: \${args.newStatus}\,
}),
})
}
},
})
`
Building the Dashboard UI
Stats Grid — Live Numbers
The stats grid at the top shows KPIs that update in real time:
// convex/dashboard.ts
export const getStats = query({
handler: async (ctx) => {
const now = Date.now()const allOrders = await ctx.db.query('orders').collect() const recentOrders = allOrders.filter( (o) => o.createdAt > thirtyDaysAgo )
const activeOrders = allOrders.filter( (o) => !['completed', 'cancelled'].includes(o.status) )
const revenue = recentOrders.reduce( (sum, o) => sum + o.totalAmount, 0 )
const completedThisMonth = recentOrders.filter( (o) => o.status === 'completed' ).length
return {
activeOrders: activeOrders.length,
monthlyRevenue: revenue,
completedThisMonth,
avgFulfillmentDays: calculateAvgFulfillment(recentOrders),
}
},
})
`
When someone marks an order complete, every dashboard showing these stats updates instantly. No refresh button. No stale numbers.
Production Timeline — Visual Pipeline
The production timeline shows orders moving through stages. This was the most complex component because it needed to:
1. Show all active orders grouped by production stage 2. Allow drag-and-drop to move orders between stages 3. Update in real time when someone else moves an order 4. Handle conflicts (two people moving the same order simultaneously)
Convex's optimistic updates handle the conflict case elegantly:
import { useMutation, useQuery } from 'convex/react' import { api } from '@/convex/_generated/api'
export function ProductionTimeline() { const orders = useQuery(api.orders.getActiveOrders, {}) const updateStatus = useMutation(api.orders.updateStatus)
const stages = [ 'pending', 'confirmed', 'in_production', 'quality_check', 'packaging', 'shipped', ]
const handleDrop = async ( orderId: string, newStatus: string ) => { await updateStatus({ orderId, status: newStatus }) // Convex handles optimistic update — UI updates immediately // If the mutation fails, it rolls back automatically }
return (
{stages.map((stage) => (
o.status === stage) ?? []}
onDrop={handleDrop}
/>
))}
)
}
`
Activity Feed — Who Did What, When
The activity feed shows a chronological stream of all actions. This is where Convex's reactivity really shines — new activities appear at the top of the feed the moment they happen, across all connected clients.
// convex/activity.ts
export const getRecent = query({
args: { limit: v.number() },
handler: async (ctx, args) => {
const activities = await ctx.db
.query('activity_log')
.withIndex('by_timestamp')
.order('desc') return Promise.all(
activities.map(async (activity) => {
const user = await ctx.db.get(activity.userId)
return {
...activity,
userName: user?.name ?? 'System',
userAvatar: user?.avatar,
}
})
)
},
})
`
Performance: The Numbers
After deploying the dashboard and running it for 30 days, here are the measured results:
| Metric | Before (Spreadsheet + Admin) | After (Convex Dashboard) |
|---|---|---|
| Data freshness | 30 min — 24 hours | < 100ms |
| Time to find order status | 2-5 minutes | Instant (search + filter) |
| Status update propagation | Manual (WhatsApp message) | Automatic to all screens |
| Duplicate work incidents | 3-4 per week | Zero |
| Customer status inquiries | 15-20 per day | 2-3 per day (self-service portal) |
| Dashboard load time | 4.2s (admin panel) | 1.1s (Next.js + edge) |
The biggest win wasn't technical — it was behavioral. When everyone sees the same live data, communication overhead drops dramatically. The WhatsApp group that used to have 50+ status update messages per day went down to actual discussions and decisions.
Lessons Learned
1. Reactive Queries Change How You Think About Data
With traditional REST APIs, you think in terms of requests: "fetch the data, display it, set up a refresh interval." With Convex, you think in terms of dependencies: "this component depends on this data, keep it current." It's a subtle but powerful shift.
2. Optimistic Updates Are Not Optional
In a multi-user real-time app, latency between clicking a button and seeing the result must be imperceptible. Convex's built-in optimistic updates handle this, but you need to design your UI to gracefully handle rollbacks when optimistic updates conflict with reality.
3. Schema Design Matters More in Real-Time
Every query subscription is a live connection. If your queries are inefficient — scanning entire tables, doing complex joins — you're paying that cost continuously, not just once per request. We spent significant time optimizing indexes and query patterns to keep subscription overhead minimal.
4. Not Everything Needs to Be Real-Time
We initially made everything reactive — including historical reports and analytics. This was unnecessary and wasteful. Historical data doesn't change. We moved reporting queries to standard server-side data fetching in Next.js, reserving Convex subscriptions for genuinely live data.
The Stack Summary
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js (App Router) | SSR, code splitting, edge deployment |
| Styling | Tailwind CSS | Utility-first, zero unused CSS |
| Real-time Data | Convex | Reactive queries, automatic subscriptions |
| Authentication | Convex + custom JWT | Integrated with backend, role-based access |
| Deployment | Vercel (frontend) + Convex Cloud (backend) | Zero-config, auto-scaling |
| Notifications | SendGrid + custom webhooks | Triggered by Convex actions |
Should You Use This Stack?
This stack excels when:
- **Multiple users need to see the same live data** — dashboards, collaboration tools, order management
- **Data changes frequently** — status updates, inventory, production tracking
- **You want to move fast** — Convex eliminates an enormous amount of boilerplate (API routes, WebSocket management, cache invalidation)
- **You're already in the React/Next.js ecosystem** — the integration is seamless
It's probably overkill if:
- Your data changes once a day and a simple admin panel is fine
- You need complex SQL queries with multiple table joins (Convex is a document database)
- You're not using React on the frontend
What's Next
We're extending this architecture to include:
- **Predictive analytics** — using historical order data to forecast production capacity and suggest optimal scheduling
- **Automated reordering** — when inventory for specific materials drops below threshold, automatically generate purchase orders
- **Customer portal** — a self-service view where customers can track their orders in real time without contacting support
The foundation is solid. That's the power of getting the real-time layer right from the start — every feature you add inherits the reactivity for free.
---
Building a real-time dashboard for your business operations? We've done it, and the results speak for themselves. Get in touch to discuss your project.
Tags