How to build a dashboard backend in Node.js
A dashboard backend needs to serve aggregated data efficiently, handle authentication, and expose clean REST endpoints that your frontend can consume without over-fetching. As the creator of CoreUI with 25 years of backend development experience, I’ve built the API layers that power CoreUI’s admin templates and know what structure scales well from prototype to production. The key is organizing the project by feature, not by layer — routes, controllers, and services grouped together for each domain area. This makes the codebase navigable as it grows and keeps related logic colocated.
Set up the Express app with JSON parsing, CORS, and a health check route.
// src/app.js
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import { usersRouter } from './users/users.router.js'
import { statsRouter } from './stats/stats.router.js'
import { authRouter } from './auth/auth.router.js'
import { authMiddleware } from './auth/auth.middleware.js'
import { errorHandler } from './middleware/error.handler.js'
const app = express()
app.use(helmet())
app.use(cors({ origin: process.env.CLIENT_ORIGIN }))
app.use(express.json())
// Public routes
app.use('/api/auth', authRouter)
app.get('/health', (req, res) => res.json({ status: 'ok' }))
// Protected routes
app.use('/api', authMiddleware)
app.use('/api/users', usersRouter)
app.use('/api/stats', statsRouter)
app.use(errorHandler)
export { app }
helmet sets security headers. cors restricts cross-origin requests to your frontend domain. Grouping public and protected routes with the auth middleware ensures every /api/* route (except /api/auth) requires a valid token.
Dashboard Stats Endpoint
Return aggregated metrics in a single request to reduce round trips.
// src/stats/stats.router.js
import { Router } from 'express'
import { getOverviewStats } from './stats.service.js'
export const statsRouter = Router()
statsRouter.get('/overview', async (req, res, next) => {
try {
const stats = await getOverviewStats()
res.json(stats)
} catch (err) {
next(err)
}
})
// src/stats/stats.service.js
import { db } from '../db/client.js'
export async function getOverviewStats() {
const [users, orders, revenue] = await Promise.all([
db.users.count(),
db.orders.count({ where: { status: 'completed' } }),
db.orders.aggregate({ _sum: { total: true } })
])
return {
totalUsers: users,
completedOrders: orders,
totalRevenue: revenue._sum.total ?? 0,
generatedAt: new Date().toISOString()
}
}
Promise.all runs all three database queries concurrently, reducing the response time to the slowest single query rather than the sum of all three. The service returns a plain object — no Express logic, easy to unit test.
JWT Authentication Middleware
Protect routes by validating Bearer tokens.
// src/auth/auth.middleware.js
import jwt from 'jsonwebtoken'
export function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid token' })
}
const token = authHeader.slice(7)
try {
req.user = jwt.verify(token, process.env.JWT_SECRET)
next()
} catch {
res.status(401).json({ error: 'Token expired or invalid' })
}
}
jwt.verify throws if the token is expired or has an invalid signature. Catching the error and returning 401 prevents the request from reaching the route handler. The decoded payload is attached to req.user so subsequent handlers can access the user’s ID and roles.
Centralized Error Handler
Handle all errors in one place and return consistent responses.
// src/middleware/error.handler.js
export function errorHandler(err, req, res, next) {
console.error(err)
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message })
}
if (err.code === 'P2025') {
// Prisma not found error
return res.status(404).json({ error: 'Resource not found' })
}
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message
})
}
Passing four arguments to the middleware function signals Express that it’s an error handler. Hiding the raw error message in production prevents leaking internal details to clients. In development, the full message makes debugging faster.
Best Practice Note
This is the same layered architecture we use in the backend that powers CoreUI Pro templates. Keep routes thin — they should only call a service and return the result. Services own business logic. This separation makes it easy to build a todo API or any other feature following the same pattern. For production deployments, add request logging with Pino and rate limiting with express-rate-limit before going live.



