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(flux: string): Promise {
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 (
Fleet Overview
)
}
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 (
{deviceId}
{online ? '● Live' : '○ Offline'}
{temp.toFixed(1)}°
Temperature
{humid.toFixed(0)}%
Humidity
)
}
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 minutesexport default async function AnalyticsPage({ params }) {
const data = await get24hSummary(params.deviceId)
return
}
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.