Next.js Integration

Use CloudSignal MQTT client in Next.js applications with Supabase

Next.js Integration

Best practices for integrating CloudSignal into Next.js applications.

Installation

npm install @cloudsignal/mqtt-client

Client-Side Only

CloudSignal MQTT uses WebSockets, so it must run client-side only. Always use the "use client" directive.

Quick Start Hook

Create a reusable hook with React StrictMode support:

// hooks/useMQTT.ts
"use client";

import { useEffect, useRef, useState, useCallback } from 'react';
import CloudSignal from '@cloudsignal/mqtt-client';

interface Message {
  topic: string;
  payload: unknown;
  receivedAt: number;
}

export function useMQTT(options = {}) {
  const { 
    debug = false, 
    tokenServiceUrl = 'https://auth.cloudsignal.app',
    preset = 'desktop' 
  } = options;

  const [isConnected, setIsConnected] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);

  // Refs prevent StrictMode double-connection
  const clientRef = useRef(null);
  const connectingRef = useRef(false);
  const mountedRef = useRef(true);

  const connect = useCallback(async (config) => {
    if (connectingRef.current || clientRef.current) return;
    connectingRef.current = true;
    setError(null);

    try {
      const client = new CloudSignal({ debug, preset, tokenServiceUrl });

      client.onConnectionStatusChange = (connected) => {
        if (mountedRef.current) setIsConnected(connected);
      };

      client.onAuthError = (err) => {
        if (mountedRef.current) setError(err);
        clientRef.current = null;
      };

      client.onMessage((topic, message) => {
        const payload = (() => {
          try { return JSON.parse(message); } 
          catch { return message; }
        })();
        setMessages(prev => 
          [{ topic, payload, receivedAt: Date.now() }, ...prev].slice(0, 100)
        );
      });

      await client.connectWithToken(config);

      if (!mountedRef.current) {
        client.destroy();
        return;
      }

      clientRef.current = client;
    } catch (err) {
      if (mountedRef.current) setError(err);
    } finally {
      connectingRef.current = false;
    }
  }, [debug, preset, tokenServiceUrl]);

  const disconnect = useCallback(() => {
    clientRef.current?.destroy();
    clientRef.current = null;
    setIsConnected(false);
  }, []);

  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      clientRef.current?.destroy();
    };
  }, []);

  return {
    isConnected,
    error,
    messages,
    connect,
    disconnect,
    subscribe: (topic, qos = 1) => clientRef.current?.subscribe(topic, qos),
    publish: (topic, msg, opts) => clientRef.current?.transmit(topic, typeof msg === 'string' ? msg : JSON.stringify(msg), opts),
  };
}

With Supabase Auth

Complete integration with automatic token refresh:

// hooks/useMQTTWithSupabase.ts
"use client";

import { useEffect, useRef, useState, useCallback } from 'react';
import CloudSignal from '@cloudsignal/mqtt-client';
import { createBrowserClient } from '@supabase/ssr';

const supabase = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export function useMQTTWithSupabase({ autoConnect = true, initialTopics = [] } = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState([]);
  const clientRef = useRef(null);
  const connectingRef = useRef(false);
  const mountedRef = useRef(true);

  const connect = useCallback(async () => {
    if (connectingRef.current || clientRef.current) return;

    const { data: { session } } = await supabase.auth.getSession();
    if (!session?.access_token) return;

    connectingRef.current = true;

    try {
      const client = new CloudSignal({
        tokenServiceUrl: 'https://auth.cloudsignal.app',
        preset: 'desktop',
      });

      client.onConnectionStatusChange = (connected) => {
        if (mountedRef.current) setIsConnected(connected);
      };

      client.onAuthError = () => {
        clientRef.current = null; // Allow reconnect
      };

      client.onMessage((topic, message) => {
        try {
          setMessages(prev => [{ topic, payload: JSON.parse(message) }, ...prev].slice(0, 100));
        } catch {
          setMessages(prev => [{ topic, payload: message }, ...prev].slice(0, 100));
        }
      });

      await client.connectWithToken({
        host: 'wss://connect.cloudsignal.app:18885/',
        organizationId: process.env.NEXT_PUBLIC_CLOUDSIGNAL_ORG_ID!,
        externalToken: session.access_token,
      });

      if (!mountedRef.current) {
        client.destroy();
        return;
      }

      clientRef.current = client;

      // Subscribe to initial topics
      for (const topic of initialTopics) {
        await client.subscribe(topic);
      }
    } finally {
      connectingRef.current = false;
    }
  }, [initialTopics]);

  const disconnect = useCallback(() => {
    clientRef.current?.destroy();
    clientRef.current = null;
    setIsConnected(false);
  }, []);

  useEffect(() => {
    mountedRef.current = true;
    if (autoConnect) connect();

    // Handle auth state changes (token refresh, sign out)
    const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
      if (event === 'SIGNED_OUT') {
        disconnect();
      } else if (event === 'TOKEN_REFRESHED' && session) {
        // Reconnect with new token
        disconnect();
        setTimeout(connect, 100);
      } else if (event === 'SIGNED_IN' && !clientRef.current) {
        connect();
      }
    });

    return () => {
      mountedRef.current = false;
      subscription.unsubscribe();
      disconnect();
    };
  }, [autoConnect, connect, disconnect]);

  return {
    isConnected,
    messages,
    subscribe: (topic) => clientRef.current?.subscribe(topic),
    publish: (topic, msg) => clientRef.current?.transmit(topic, typeof msg === 'string' ? msg : JSON.stringify(msg)),
    disconnect,
  };
}

Context Provider

For app-wide access:

// contexts/MQTTContext.tsx
"use client";

import { createContext, useContext, ReactNode } from 'react';
import { useMQTTWithSupabase } from '@/hooks/useMQTTWithSupabase';

const MQTTContext = createContext(null);

export function MQTTProvider({ children }: { children: ReactNode }) {
  const mqtt = useMQTTWithSupabase();
  return (
    <MQTTContext.Provider value={mqtt}>
      {children}
    </MQTTContext.Provider>
  );
}

export function useMQTTContext() {
  const context = useContext(MQTTContext);
  if (!context) {
    throw new Error('useMQTTContext must be used within MQTTProvider');
  }
  return context;
}

Root Layout

// app/layout.tsx
import { MQTTProvider } from '@/contexts/MQTTContext';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <MQTTProvider>{children}</MQTTProvider>
      </body>
    </html>
  );
}

Real-Time Dashboard

// app/dashboard/page.tsx
"use client";

import { useEffect } from 'react';
import { useMQTTContext } from '@/contexts/MQTTContext';

export default function DashboardPage() {
  const { isConnected, messages, subscribe } = useMQTTContext();

  useEffect(() => {
    if (isConnected) {
      subscribe('metrics/cpu');
      subscribe('metrics/memory');
      subscribe('alerts/#');
    }
  }, [isConnected]);

  const cpuData = messages.find(m => m.topic === 'metrics/cpu');
  const memData = messages.find(m => m.topic === 'metrics/memory');
  const alerts = messages.filter(m => m.topic.startsWith('alerts/'));

  return (
    <div className="grid grid-cols-2 gap-4 p-4">
      <div className="card">
        <h2>CPU Usage</h2>
        <span className="text-4xl">{cpuData?.payload?.value ?? '--'}%</span>
      </div>
      
      <div className="card">
        <h2>Memory</h2>
        <span className="text-4xl">{memData?.payload?.value ?? '--'}%</span>
      </div>
      
      <div className="card col-span-2">
        <h2>Recent Alerts ({alerts.length})</h2>
        <ul>
          {alerts.slice(0, 5).map((alert, i) => (
            <li key={i} className={`alert-${alert.payload.level}`}>
              {alert.payload.message}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

With NextAuth.js

// hooks/useMQTTWithNextAuth.ts
"use client";

import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState, useCallback } from 'react';
import CloudSignal from '@cloudsignal/mqtt-client';

export function useMQTTWithNextAuth() {
  const { data: session, status } = useSession();
  const [isConnected, setIsConnected] = useState(false);
  const clientRef = useRef(null);
  const connectingRef = useRef(false);

  const connect = useCallback(async () => {
    if (connectingRef.current || clientRef.current || !session?.accessToken) return;
    connectingRef.current = true;

    try {
      const client = new CloudSignal({
        tokenServiceUrl: 'https://auth.cloudsignal.app',
        preset: 'desktop',
      });

      client.onConnectionStatusChange = setIsConnected;
      client.onAuthError = () => { clientRef.current = null; };

      await client.connectWithToken({
        host: 'wss://connect.cloudsignal.app:18885/',
        organizationId: process.env.NEXT_PUBLIC_CLOUDSIGNAL_ORG_ID!,
        externalToken: session.accessToken as string,
      });

      clientRef.current = client;
    } finally {
      connectingRef.current = false;
    }
  }, [session?.accessToken]);

  useEffect(() => {
    if (status === 'authenticated') {
      connect();
    }
    return () => {
      clientRef.current?.destroy();
      clientRef.current = null;
    };
  }, [status, connect]);

  return { isConnected, client: clientRef.current };
}

Environment Variables

# .env.local
NEXT_PUBLIC_CLOUDSIGNAL_ORG_ID=your-org-uuid
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

SSR Considerations

Since CloudSignal requires a browser environment:

  1. Always use "use client" for components using the client
  2. Check typeof window !== 'undefined' if needed
  3. Use dynamic imports for optional loading:
import dynamic from 'next/dynamic';

const LiveChat = dynamic(
  () => import('@/components/LiveChat'),
  { ssr: false }
);

Troubleshooting

React StrictMode Double Connections

The hooks above include guards (connectingRef, mountedRef) that prevent duplicate connections in development mode.

Token Expiry

The useMQTTWithSupabase hook automatically handles token refresh by listening to onAuthStateChange.

Debug Mode

Enable detailed logging:

const mqtt = useMQTT({ debug: true });