redXtrm
AI Agent SystemsBusiness AutomationRAG ChatbotsVoice + WhatsApp AgentsCustom AI WorkflowsCustom Web AppsE-Commerce PlatformsAPI + Backend BuildsDatabase ArchitecturePerformance OptimizationAI Agent SystemsBusiness AutomationRAG ChatbotsVoice + WhatsApp AgentsCustom AI WorkflowsCustom Web AppsE-Commerce PlatformsAPI + Backend BuildsDatabase ArchitecturePerformance Optimization
web developmentai automation

Building a Real-Time Operations Dashboard with Convex and Next.js

redxtrm

redxtrm

Full-stack developer and business consultant specializing in Next.js, React, and e-commerce solutions.

February 16, 202614 min read
Building a Real-Time Operations Dashboard with Convex and Next.js

# 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 shell

export default function DashboardLayout({ children, }: { children: React.ReactNode }) { return {children} } `

// app/dashboard/page.tsx — Server Component wrapper

export default function DashboardPage() { return } `

// components/dashboard/content.tsx — Client Component

import { 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.tsx

import { 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:

MetricBefore (Spreadsheet + Admin)After (Convex Dashboard)
Data freshness30 min — 24 hours< 100ms
Time to find order status2-5 minutesInstant (search + filter)
Status update propagationManual (WhatsApp message)Automatic to all screens
Duplicate work incidents3-4 per weekZero
Customer status inquiries15-20 per day2-3 per day (self-service portal)
Dashboard load time4.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

LayerTechnologyWhy
FrontendNext.js (App Router)SSR, code splitting, edge deployment
StylingTailwind CSSUtility-first, zero unused CSS
Real-time DataConvexReactive queries, automatic subscriptions
AuthenticationConvex + custom JWTIntegrated with backend, role-based access
DeploymentVercel (frontend) + Convex Cloud (backend)Zero-config, auto-scaling
NotificationsSendGrid + custom webhooksTriggered 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

ConvexNext.jsreal-timedashboardoperationsReactWebSocket

Share