Back to Blog
IoT Dashboard

Next.js IoT Analytics Dashboard: From Sensor Data to Production App

Next.js 14 with App Router is an excellent foundation for IoT analytics dashboards — server components eliminate data-fetching waterfalls, API routes connect to InfluxDB, and ISR keeps pages fast without stale data. Here's the complete architecture.

October 28, 2024
13 min read
Next.jsIoT DashboardInfluxDBApp Router

Next.js IoT Analytics Dashboard: From Sensor Data to Production App

Grafana is the go-to for operational monitoring. But when your IoT product needs a customer-facing analytics dashboard — branded, embedded in your web app, with custom business logic — Next.js 14 is the right foundation.

This guide builds a production IoT analytics dashboard with Next.js App Router, InfluxDB for time-series data, server components for performance, and real-time client components for live sensor readings.

Architecture

User Browser
  └── Next.js App Router
        ├── Server Components → InfluxDB (historical data)
        ├── Client Components → WebSocket (real-time data)
        └── API Routes → InfluxDB (dynamic queries)

InfluxDB ← Telegraf ← MQTT Broker ← IoT Devices

Project Setup

npx create-next-app@latest iot-analytics --typescript --tailwind --app
cd iot-analytics
npm install @influxdata/influxdb-client next-auth recharts

# .env.local
INFLUX_URL=http://localhost:8086
INFLUX_TOKEN=your-influxdb-token
INFLUX_ORG=codecaracal
INFLUX_BUCKET=iot_telemetry
NEXTAUTH_SECRET=your-secret
NEXTAUTH_URL=http://localhost:3000
WS_URL=ws://localhost:8080

Step 1: InfluxDB Client Singleton

// lib/influx.ts
import { InfluxDB, QueryApi } from '@influxdata/influxdb-client'

let queryApi: QueryApi | null = null

export function getInfluxQueryApi(): QueryApi { if (!queryApi) { const client = new InfluxDB({ url: process.env.INFLUX_URL!, token: process.env.INFLUX_TOKEN!, }) queryApi = client.getQueryApi(process.env.INFLUX_ORG!) } return queryApi }

export async function queryInflux<T>(flux: string): Promise<T[]> { const api = getInfluxQueryApi() const rows: T[] = []

return new Promise((resolve, reject) => { api.queryRows(flux, { next: (row, meta) => rows.push(meta.toObject(row) as T), error: reject, complete: () => resolve(rows), }) }) }

Step 2: Device Overview — Server Component

Server components run on the server at request time. No client JS, no loading spinner for initial data:

// app/dashboard/page.tsx (Server Component)
import { queryInflux } from '@/lib/influx'
import { DeviceGrid } from '@/components/DeviceGrid'

interface DeviceRow { device_id: string _value: number _field: string _time: string }

async function getDeviceSummary() { const flux = ` from(bucket: "${process.env.INFLUX_BUCKET}") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "device_telemetry") |> filter(fn: (r) => r._field == "temperature" or r._field == "humidity") |> group(columns: ["device_id", "_field"]) |> last() |> pivot(rowKey: ["device_id"], columnKey: ["_field"], valueColumn: "_value") `

return queryInflux<{ device_id: string temperature: number humidity: number _time: string }>(flux) }

export default async function DashboardPage() { const devices = await getDeviceSummary()

return ( <main className="container mx-auto p-6"> <h1 className="text-2xl font-bold text-white mb-6">Fleet Overview</h1> <DeviceGrid devices={devices} /> </main> ) }

Step 3: API Routes for Dynamic Queries

Client components and external consumers fetch data via API routes:

// app/api/devices/[deviceId]/history/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { queryInflux } from '@/lib/influx'
import { getServerSession } from 'next-auth'

export async function GET( req: NextRequest, { params }: { params: { deviceId: string } } ) { // Auth check — only device owners can query their devices const session = await getServerSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const { searchParams } = new URL(req.url) const range = searchParams.get('range') ?? '-24h' const field = searchParams.get('field') ?? 'temperature'

// Validate inputs to prevent Flux injection const validRanges = ['-1h', '-6h', '-24h', '-7d', '-30d'] const validFields = ['temperature', 'humidity', 'voltage', 'pressure'] if (!validRanges.includes(range)) return NextResponse.json({ error: 'Invalid range' }, { status: 400 }) if (!validFields.includes(field)) return NextResponse.json({ error: 'Invalid field' }, { status: 400 })

const flux = ` from(bucket: "${process.env.INFLUX_BUCKET}") |> range(start: ${range}) |> filter(fn: (r) => r._measurement == "device_telemetry") |> filter(fn: (r) => r._field == "${field}") |> filter(fn: (r) => r.device_id == "${params.deviceId}") |> aggregateWindow(every: 5m, fn: mean, createEmpty: false) `

const data = await queryInflux<{ _time: string; _value: number }>(flux) return NextResponse.json(data) }

Step 4: Real-Time Client Component

// components/LiveSensorCard.tsx (Client Component)
'use client'

import { useState, useEffect } from 'react' import { LineChart, Line, YAxis, ResponsiveContainer } from 'recharts'

interface Props { deviceId: string initialTemp: number initialHumid: number }

export function LiveSensorCard({ deviceId, initialTemp, initialHumid }: Props) { const [temp, setTemp] = useState(initialTemp) const [humid, setHumid] = useState(initialHumid) const [history, setHistory] = useState<{ t: number; temp: number }[]>([]) const [online, setOnline] = useState(true)

useEffect(() => { const ws = new WebSocket(`${process.env.NEXT_PUBLIC_WS_URL}/device/${deviceId}`)

ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'telemetry') { setTemp(data.payload.temperature) setHumid(data.payload.humidity) setOnline(true) setHistory(prev => [ ...prev.slice(-59), { t: Date.now(), temp: data.payload.temperature } ]) } }

ws.onclose = () => setOnline(false) return () => ws.close() }, [deviceId])

return ( <div className="bg-gray-900 rounded-xl p-5 border border-gray-700"> <div className="flex justify-between items-center mb-4"> <span className="text-white font-medium">{deviceId}</span> <span className={online ? 'text-green-400 text-sm' : 'text-red-400 text-sm'}> {online ? '● Live' : '○ Offline'} </span> </div> <div className="grid grid-cols-2 gap-3 mb-4"> <div> <div className="text-3xl font-mono font-bold text-orange-400">{temp.toFixed(1)}°</div> <div className="text-gray-500 text-xs">Temperature</div> </div> <div> <div className="text-3xl font-mono font-bold text-blue-400">{humid.toFixed(0)}%</div> <div className="text-gray-500 text-xs">Humidity</div> </div> </div> <ResponsiveContainer width="100%" height={60}> <LineChart data={history}> <YAxis domain={['auto', 'auto']} hide /> <Line type="monotone" dataKey="temp" stroke="#FF6B35" dot={false} strokeWidth={1.5} isAnimationActive={false} /> </LineChart> </ResponsiveContainer> </div> ) }

Step 5: Authentication with NextAuth

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'

const handler = NextAuth({ providers: [ CredentialsProvider({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { const user = await validateUser(credentials!.email, credentials!.password) if (!user) return null return { id: user.id, email: user.email, deviceIds: user.deviceIds, } }, }), ], callbacks: { async jwt({ token, user }) { if (user) token.deviceIds = (user as any).deviceIds return token }, async session({ session, token }) { (session.user as any).deviceIds = token.deviceIds return session }, }, pages: { signIn: '/login' }, })

export { handler as GET, handler as POST }

Incremental Static Regeneration for Analytics Pages

For historical analytics pages that don't need real-time data:

// app/analytics/[deviceId]/page.tsx
export const revalidate = 300  // Regenerate every 5 minutes

export default async function AnalyticsPage({ params }) { const data = await get24hSummary(params.deviceId) return <AnalyticsView data={data} /> }

ISR serves pre-generated pages instantly while keeping data fresh — no spinner, no loading state, just fast pages.

Want a customer-facing IoT analytics dashboard built for your product? [Contact Code Caracal](/contact) — we build branded Next.js IoT dashboards used by enterprise clients across 15+ countries.

⚡
Written by CodeCaracal Engineering

We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

Let's Build Together

Got an IoT challenge?
We've shipped it.

Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

No upfront commitment99.9% uptime SLANDA on requestFixed-price options