This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia

May 6, 2026

🇬🇧 English

Fleet Management Part 2: Building the Real-Time Dashboard with Next.js

Step-by-step guide to building a real-time fleet tracking dashboard with Next.js and TypeScript. SSR, component architecture, and performance optimization.

7 min read

What is Next.js?

If you're new to Next.js, let me explain it simply. Next.js is React with superpowers. Regular React runs entirely in the browser — the server sends an empty HTML page, then JavaScript builds the page in the user's browser. Next.js can render pages on the server before sending them, so users see content instantly.

Why does this matter for our fleet dashboard? Our operators open the dashboard first thing in the morning, often on slow office WiFi. With server-side rendering, they see the fleet map immediately instead of staring at a loading spinner.

SSR vs CSR vs ISR — When to Use What

StrategyHow It WorksBest ForOur Usage
SSR (Server-Side Rendering)Page rendered on server for each requestDynamic data that changes per-requestDashboard main page
CSR (Client-Side Rendering)Page rendered in browserHighly interactive UIs, after initial loadMap interactions, real-time updates
ISR (Incremental Static Regeneration)Pre-built pages, regenerated periodicallyContent that changes infrequentlyReports, driver profiles
SSG (Static Site Generation)Pre-built at build timeContent that never changesHelp pages, docs

Senior Decision: Our dashboard uses SSR for the initial page load (get fresh data from the API) and then switches to CSR with WebSocket for real-time updates. This gives us the best of both worlds — fast initial load + live updates.


Project Setup

Let's set up our Next.js frontend with TypeScript in strict mode:

npx create-next-app@latest fleet-web --typescript --eslint --src-dir
cd fleet-web

TypeScript Strict Mode — Why It Matters

Open tsconfig.json and ensure strict mode is enabled:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Why strict mode? Because undefined is not a function is the most common error in JavaScript. Strict TypeScript catches these bugs at compile time, not at 2 AM when your operator can't see any trucks on the map.

Common Mistake: Many developers enable strict mode but then scatter any types everywhere to shut up the compiler. Don't do this. If you need any, you're probably missing a type definition.


Component Architecture

Before we write any components, let's plan our architecture. In a large application, component organization can make or break your productivity.

The Component Classification System

I organize components into 4 categories:

src/ ├── components/ │ ├── ui/ # Reusable, stateless UI primitives │ │ ├── Button.tsx │ │ ├── Card.tsx │ │ ├── Badge.tsx │ │ └── Modal.tsx │ │ │ ├── features/ # Feature-specific components with business logic │ │ ├── fleet/ │ │ │ ├── FleetMap.tsx │ │ │ ├── VehicleCard.tsx │ │ │ └── VehicleStatusBadge.tsx │ │ ├── driver/ │ │ │ ├── DriverList.tsx │ │ │ └── DriverProfile.tsx │ │ └── alert/ │ │ ├── AlertPanel.tsx │ │ └── AlertItem.tsx │ │ │ ├── layouts/ # Page layout components │ │ ├── DashboardLayout.tsx │ │ ├── Sidebar.tsx │ │ └── TopBar.tsx │ │ │ └── providers/ # Context providers │ ├── AuthProvider.tsx │ └── WebSocketProvider.tsx

Why this structure? When a new developer joins the team and needs to fix a bug in the fleet map, they know to look in components/features/fleet/. When someone needs to change a button style, they go to components/ui/. Clear organization = faster onboarding.


Building the Dashboard Layout

Let's build our dashboard layout step by step:

// src/components/layouts/DashboardLayout.tsx
import { ReactNode, useState } from 'react';
import Sidebar from './Sidebar';
import TopBar from './TopBar';

interface DashboardLayoutProps {
  children: ReactNode;
  title: string;
}

export default function DashboardLayout({ children, title }: DashboardLayoutProps) {
  const [isSidebarOpen, setIsSidebarOpen] = useState(true);

  return (
    <div className="dashboard">
      <Sidebar isOpen={isSidebarOpen} />
      <div className={`main-content ${isSidebarOpen ? 'with-sidebar' : 'full-width'}`}>
        <TopBar
          title={title}
          onToggleSidebar={() => setIsSidebarOpen(prev => !prev)}
        />
        <main className="page-content">
          {children}
        </main>
      </div>
    </div>
  );
}

The Vehicle Card Component

This is a great example of a well-structured feature component:

// src/components/features/fleet/VehicleCard.tsx
import { Badge } from '@/components/ui/Badge';

// Always define your types explicitly
interface Vehicle {
  id: string;
  plateNumber: string;
  driverName: string;
  status: 'active' | 'idle' | 'maintenance' | 'offline';
  fuelLevel: number;      // 0-100 percentage
  speed: number;           // km/h
  lastUpdate: Date;
  location: {
    lat: number;
    lng: number;
    address: string;
  };
}

interface VehicleCardProps {
  vehicle: Vehicle;
  isSelected: boolean;
  onSelect: (vehicleId: string) => void;
}

const STATUS_CONFIG = {
  active:      { label: 'Active',      color: 'green' },
  idle:        { label: 'Idle',        color: 'yellow' },
  maintenance: { label: 'Maintenance', color: 'orange' },
  offline:     { label: 'Offline',     color: 'red' },
} as const;

export default function VehicleCard({ vehicle, isSelected, onSelect }: VehicleCardProps) {
  const statusConfig = STATUS_CONFIG[vehicle.status];
  const timeSinceUpdate = getTimeSinceUpdate(vehicle.lastUpdate);

  return (
    <div
      className={`vehicle-card ${isSelected ? 'selected' : ''}`}
      onClick={() => onSelect(vehicle.id)}
      role="button"
      tabIndex={0}
      aria-label={`Vehicle ${vehicle.plateNumber}, status: ${statusConfig.label}`}
    >
      <div className="vehicle-card__header">
        <h3 className="vehicle-card__plate">{vehicle.plateNumber}</h3>
        <Badge color={statusConfig.color}>{statusConfig.label}</Badge>
      </div>

      <div className="vehicle-card__details">
        <p className="vehicle-card__driver">🧑‍✈️ {vehicle.driverName}</p>
        <p className="vehicle-card__location">📍 {vehicle.location.address}</p>
        <p className="vehicle-card__speed">🚚 {vehicle.speed} km/h</p>
      </div>

      <div className="vehicle-card__footer">
        <FuelGauge level={vehicle.fuelLevel} />
        <span className="vehicle-card__updated">{timeSinceUpdate}</span>
      </div>
    </div>
  );
}

function FuelGauge({ level }: { level: number }) {
  const getColor = (level: number): string => {
    if (level > 50) return 'green';
    if (level > 25) return 'yellow';
    return 'red';
  };

  return (
    <div className="fuel-gauge" aria-label={`Fuel level: ${level}%`}>
      <div className="fuel-gauge__track">
        <div
          className="fuel-gauge__fill"
          style={{
            width: `${level}%`,
            backgroundColor: `var(--color-${getColor(level)})`,
          }}
        />
      </div>
      <span className="fuel-gauge__label">⛽ {level}%</span>
    </div>
  );
}

function getTimeSinceUpdate(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  if (seconds < 60) return `${seconds}s ago`;
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  return `${Math.floor(seconds / 3600)}h ago`;
}

Notice the senior patterns here:

  1. Explicit TypeScript types — no any, no guessing
  2. Constant configuration objectsSTATUS_CONFIG instead of if/else chains
  3. Accessibilityrole, tabIndex, aria-label on interactive elements
  4. Small helper functionsFuelGauge and getTimeSinceUpdate are extracted

State Management: When to Use What

State management is where many developers overthink. Here's my simple rule:

State TypeSolutionExample
UI stateuseStateSidebar open/closed, modal visible
Shared UI stateReact ContextCurrent theme, authenticated user
Server stateReact Query / SWRFleet data, driver list
Real-time stateWebSocket + useReducerLive vehicle positions

Common Mistake: Using Redux or Zustand for everything. If your state comes from an API, use a data-fetching library like React Query. It handles caching, re-fetching, and loading states — things you'd have to build yourself with Redux.

API Service Layer

Instead of calling fetch directly in components, create a service layer:

// src/services/fleet.service.ts
import type { Vehicle } from '@/types/vehicle';

const API_BASE = process.env.NEXT_PUBLIC_API_URL;

export const fleetService = {
  async getVehicles(): Promise<Vehicle[]> {
    const res = await fetch(`${API_BASE}/api/vehicles`, {
      headers: { 'Content-Type': 'application/json' },
    });

    if (!res.ok) {
      throw new Error(`Failed to fetch vehicles: ${res.status}`);
    }

    return res.json();
  },

  async getVehicleById(id: string): Promise<Vehicle> {
    const res = await fetch(`${API_BASE}/api/vehicles/${id}`);

    if (!res.ok) {
      throw new Error(`Vehicle not found: ${id}`);
    }

    return res.json();
  },
};

Why a service layer? When the API endpoint changes, you update one file — not 15 components. When you add authentication headers, you add it in one place.


Performance Optimization Tips

1. Code Splitting with Dynamic Imports

The map library is huge (200KB+). Don't load it on pages that don't need it:

import dynamic from 'next/dynamic';

// Only load the map component when needed
const FleetMap = dynamic(() => import('@/components/features/fleet/FleetMap'), {
  loading: () => <div className="map-skeleton">Loading map...</div>,
  ssr: false, // Maps don't work on the server
});

2. Memoize Expensive Computations

import { useMemo } from 'react';

function FleetDashboard({ vehicles }: { vehicles: Vehicle[] }) {
  // Only recalculate when vehicles array changes
  const stats = useMemo(() => ({
    total: vehicles.length,
    active: vehicles.filter(v => v.status === 'active').length,
    idle: vehicles.filter(v => v.status === 'idle').length,
    lowFuel: vehicles.filter(v => v.fuelLevel < 25).length,
  }), [vehicles]);

  return (
    <div className="fleet-stats">
      <StatCard label="Total Vehicles" value={stats.total} />
      <StatCard label="Active" value={stats.active} color="green" />
      <StatCard label="Idle" value={stats.idle} color="yellow" />
      <StatCard label="Low Fuel" value={stats.lowFuel} color="red" />
    </div>
  );
}

3. Image Optimization

import Image from 'next/image';

// Next.js automatically optimizes images
<Image
  src="/images/truck-marker.png"
  alt="Truck marker"
  width={32}
  height={32}
  priority // Load immediately for above-the-fold images
/>

What's Next

In Part 3, we'll build the NestJS backend API — the engine behind our fleet dashboard. We'll cover:

  • NestJS project setup and module architecture
  • Building RESTful endpoints for vehicle CRUD
  • DTOs, validation, and error handling
  • Dependency injection explained with real examples
  • Clean architecture layers

This is Part 2 of the Fleet Management System series. Each article builds on the previous one, gradually increasing complexity from beginner-friendly concepts to senior-level patterns.

Continue Reading

Previous article

← Previous Article

Fleet Management System Part 1: Introduction & System Architecture

Next Article →

Fleet Management Part 3: Backend API with NestJS

Next article