Back to Blog

Building a Chat Inside an Admiral Admin Panel

Yana Korpan - dev.family
Yana Korpan
frontend developer

Aug 18, 2025

12 minutes reading

Building a Chat Inside an Admiral Admin Panel - dev.family

When developing admin panels, we often find ourselves performing repetitive tasks such as creating forms, lists, filters, and CRUD operations. Admiral is a React framework that speeds up this process with its built-in patterns and flexible architecture for quickly building full-featured admin interfaces.

Recently, we had to build a chat feature for one of our projects. Funny enough, tools like Jivo or LiveChat weren't even part of the discussion. We knew from the beginning that we could easily create a fully custom chat feature within Admiral.

In this article, I'll explain what that process entailed and provide a hands-on tutorial on integrating advanced features, such as real-time chat, into your Admiral-based app.

Why Admiral Is a Great Fit for Custom Features

Admiral was built from the ground up with extensibility in mind. Thanks to its file-based routing, flexible component architecture, and built-in hooks, adding custom functionality feels intuitive, not like fighting the framework.

This makes building a chat application a great use case. It goes beyond standard CRUD operations, demonstrating how Admiral enables you to implement more complex features without additional overhead.

⚡Check out our framework: https://github.com/dev-family/admiral

Chat Architecture

Before we dive into the code, let’s break down the overall architecture.

Here are the core components: 

  • ChatPage – the main chat page;
  • ChatSidebar – displays the list of conversations with previews;
  • ChatPanel – renders the currently selected chat;
  • MessageFeed – the message thread;
  • MessageInput – input field with file upload support.

And here are the key context providers:

  • SocketContext – manages WebSocket connections;
  • ChatContext – manages chat and message state.

Main Chat Page

Admiral uses file-based routing, which makes creating new pages as straightforward as it gets:

// pages/chat/index.tsx

import ChatPage from '@/src/crud/chat'
export default ChatPage

That’s it! The page is now automatically available at /chat — no additional router configuration needed.

Building a Custom “Settings” Page with Admiral - dev.family

Building a Custom “Settings” Page with Admiral

You can read more about creating custom pages in this article

Now let’s move on to implementing the main logic in src/crud/chat/index.tsx

// src/crud/chat/index.tsx

import React from 'react'

import { Card } from '@devfamily/admiral'
import { usePermissions, usePermissionsRedirect } from '@devfamily/admiral'
import { SocketProvider } from './contexts/SocketContext'
import { ChatProvider } from './contexts/ChatContext'
import ChatSidebar from './components/ChatSidebar'
import ChatPanel from './components/ChatPanel'
import styles from './Chat.module.css'

export default function ChatPage() {
  const { permissions, loaded, isAdmin } = usePermissions()
  const identityPermissions = permissions?.chat?.chat
  
  usePermissionsRedirect({ identityPermissions, isAdmin, loaded })

  return (
    <SocketProvider>
      <ChatProvider>
        <Card className={styles.page}>
          <PageTitle title="Corporate chat" />
          <div className={styles.chat}>
            <ChatSidebar />
            <ChatPanel />
          </div>
        </Card>
      </ChatProvider>
    </SocketProvider>
  )
}
  • usePermissions() – retrieves the current user’s permissions and can be used for conditional UI rendering;
  • usePermissionsRedirect() – automatically redirects the user if they lack the required permissions — especially useful in admin panels;
  • Card – an Admiral UI component for wrapping content in a styled section. You can explore it on the demo page: https://admiral.dev.family/components/card
  • PageTitle – an Admiral component for consistent page headings. Demo: https://admiral.dev.family/components/typography

Managing WebSocket Connections with SocketContext

To support real-time chat functionality, we’ll use WebSockets. In this example, we’re using the Centrifuge library. All connection logic will be encapsulated in a SocketContext for better separation of concerns.

// src/crud/chat/SocketContext.tsx

import React from 'react'

import { Centrifuge } from 'centrifuge'
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react'
import { useGetIdentity } from '@devfamily/admiral'

const SocketContext = createContext(null)

export const SocketProvider = ({ children }: { children: ReactNode }) => {
    const { identity: user } = useGetIdentity()
    const [lastMessage, setLastMessage] = useState(null)
    const centrifugeRef = useRef(null)
    const subscribedRef = useRef(false)

    useEffect(() => {
        if (!user?.ws_token) return

        const WS_URL = import.meta.env.VITE_WS_URL
        if (!WS_URL) {
            console.error('❌ Missing VITE_WS_URL in env')
            return
        }

        const centrifuge = new Centrifuge(WS_URL, {
            token: user.ws_token, // Initializing the WebSocket connection with a token
        })
        
        centrifugeRef.current = centrifuge
        centrifugeRef.current.connect()

        // Subscribing to the chat channel
        const sub = centrifugeRef.current.newSubscription(`admin_chat`)

        sub.on('publication', function (ctx: any) {
		       setLastMessage(ctx.data);
        }).subscribe()

        // Cleaning up on component unmount
        return () => {
            subscribedRef.current = false
            centrifuge.disconnect()
        }
    }, [user?.ws_token])

    return (
        <SocketContext.Provider value={{ lastMessage, centrifuge: centrifugeRef.current }}>
            {children}
        </SocketContext.Provider>
    )
}

export const useSocket = () => {
    const ctx = useContext(SocketContext)
    if (!ctx) throw new Error('useSocket must be used within SocketProvider')
    return ctx
}
  • Centrifuge – a library for working with WebSocket connections;
  • useGetIdentity() – an Admiral hook that fetches the current user’s identity, including the ws_token required for authenticating the WebSocket connection;
  • useEffect – handles the initialization and lifecycle of the WebSocket connection. It’s important to clean up (centrifuge.disconnect()) on component unmount to prevent memory leaks and unwanted active connections;
  • Subscription to your_channel_name – all messages related to the admin chat will be received through this channel;
  • on('publication') handler – listens for incoming events like message_read or new_message and updates the lastMessage state accordingly.

Managing Chat State with ChatContext

ChatContext handles loading, storing, and updating conversations and messages.

// src/crud/chat/ChatContext.tsx

import React, { useRef } from "react";

import {
  createContext,
  useContext,
  useEffect,
  useState,
  useRef,
  useCallback,
} from "react";
import { useSocket } from "./SocketContext";
import { useUrlState } from "@devfamily/admiral";
import api from "../api";

const ChatContext = createContext(null);

export const ChatProvider = ({ children }) => {
  const { lastMessage } = useSocket();
  const [dialogs, setDialogs] = useState([]);
  const [messages, setMessages] = useState([]);
  const [selectedDialog, setSelectedDialog] = useState(null);
  const [urlState] = useUrlState();
  const { client_id } = urlState;

  const fetchDialogs = useCallback(async () => {
    const res = await api.dialogs();
    setDialogs(res.data || []);
  }, []);

  const fetchMessages = useCallback(async (id) => {
    const res = await api.messages(id);
    setMessages(res.data || []);
  }, []);

  useEffect(() => {
    fetchMessages(client_id);
  }, [fetchMessages, client_id]);

  useEffect(() => {
    fetchDialogs();
  }, [fetchDialogs]);

  useEffect(() => {
    if (!lastMessage) return;

    fetchDialogs();

    setMessages((prev) => [...prev, lastMessage.data]);
  }, [lastMessage]);

  const sendMessage = useCallback(
    async (value, onSuccess, onError) => {
      try {
        const res = await api.send(value);
        if (res?.data) setMessages((prev) => [...prev, res.data]);
        fetchDialogs();
        onSuccess();
      } catch (err) {
        onError(err);
      }
    },
    [messages]
  );
  
  // Within this context, you can extend the logic to:
  // – Mark messages as read (api.read())
  // – Group messages by date, and more.

  return (
    <ChatContext.Provider
      value={{
        dialogs,
        messages: groupMessagesByDate(messages),
        selectedDialog,
        setSelectedDialog,
        sendMessage,
      }}
    >
      {children}
    </ChatContext.Provider>
  );
};

export const useChat = () => {
  const ctx = useContext(ChatContext);
  if (!ctx) throw new Error("useChat must be used within ChatProvider");
  return ctx;
};
  • useUrlState – an Admiral hook for syncing state with the URL;
  • useSocket()  retrieves the latest messages from SocketContext for real-time updates;
  • fetchMessages and fetchDialogs async functions that fetch messages and the list of conversations from the server;
  • useEffect for lastMessage handles incoming WebSocket messages;
  • sendMessagea function that sends messages to the server and also updates the local chat state and conversation list.

Everything is neatly isolated and easy to reuse.

API Client Example

// src/crud/chat/api.ts

import _ from '../../config/request'
import { apiUrl } from '@/src/config/api'

const api = {
    dialogs: () => _.get(`${apiUrl}/chat/dialogs`)(),
    messages: (id) => _.get(`${apiUrl}/chat/messages/${id}`)(),
    send: (data) => _.postFD(`${apiUrl}/chat/send`)({ data }),
    read: (data) => _.post(`${apiUrl}/chat/read`)({ data }),
}

export default api
  • _ – utility functions for working with the API (GET, POST, FormData).

UI Components: Sidebar + Panel + Input

 Below are the core UI components that make up the chat interface.

4.1. ChatSidebar – Conversation List

This component simply renders the list of conversations retrieved from useChat(), using ChatSidebarItem for each conversation.

// src/crud/chat/components/ChatSidebar.tsx

import React from "react";

import styles from "./ChatSidebar.module.scss";
import ChatSidebarItem from "../ChatSidebarItem/ChatSidebarItem";
import { useChat } from "../../model/ChatContext";

function ChatSidebar({}) {
  const { dialogs } = useChat();
  
    if (!dialogs.length) {
    return (
      <div className={styles.empty}>
        <span>No active активных dialogs</span>
      </div>
    );
  }

  return <div className={styles.list}>
      {dialogs.map((item) => (
        <ChatSidebarItem key={item.id} data={item} />
      ))}
    </div>
}

export default ChatSidebar;

4.2. ChatSidebarItem – Conversation List Item

This component displays details for each conversation: the client’s name, time of the last message, read/unread status, and a short text snippet or image preview.

// src/crud/chat/components/ChatSidebarItem.tsx

import React from "react";

import { Badge } from '@devfamily/admiral'
import dayjs from "dayjs";
import { BsCheck2, BsCheck2All } from "react-icons/bs";
import styles from "./ChatSidebarItem.module.scss";

function ChatSidebarItem({ data }) {
  const { client_name, client_id, last_message, last_message_ } = data;

  const [urlState, setUrlState] = useUrlState();
  const { client_id } = urlState;

  const { setSelectedDialog } = useChat();

  const onSelectDialog = useCallback(() => {
    setUrlState({ client_id: client.id });
    setSelectedDialog(data);
  }, [order.id]);

  return (
    <div
      className={`${styles.item} ${isSelected ? styles.active : ""}`}
      onClick={onSelectDialog}
      role="button"
    >
      <div className={styles.avatar}>{client_name.charAt(0).toUpperCase()}</div>

      <div className={styles.content}>
        <div className={styles.header}>
          <span className={styles.name}>{client_name}</span>
          <span className={styles.time}>
            {dayjs(last_message_).format("HH:mm")}
            {message.is_read ? (
              <BsCheck2All size="16px" />
            ) : (
              <BsCheck2 size="16px" />
            )}
          </span>
        </div>
        <span className={styles.preview}>{last_message.text}</span>
        {unread_count > 0 && (
            <Badge>{unread_count}</Badge>
          )}
      </div>
    </div>
  );
}

export default ChatSidebarItem;
  • useUrlState – syncs state with the URL to preserve the selected conversation after a page reload;
  • Badge – a UI component for displaying the unread message count. You can check it out on the demo page: https://admiral.dev.family/components/badge.

4.3 ChatPanel – Message Panel

This component is responsible for displaying the selected conversation. It includes the MessageFeed (message thread) and MessageInput (message input field).

// src/crud/chat/components/ChatPanel.tsx

import React from "react";

import { Card } from '@devfamily/admiral';
import { useChat } from "../../contexts/ChatContext";
import MessageFeed from "../MessageFeed";
import MessageInput from "../MessageInput";
import styles from "./ChatPanel.module.scss";

function ChatPanel() {
  const { selectedDialog } = useChat();
  
  if (!selectedDialog) {
    return (
      <Card className={styles.emptyPanel}>
        <div className={styles.emptyState}>
          <h3>Choose the dialog</h3>
          <p>Choose the dialog from the list to start conversation</p>
        </div>
      </Card>
    );
  }
  
  return (
    <div className={styles.panel}>
      <MessageFeed />
      <div className={styles.divider} />
      <MessageInput />
    </div>
  );
}

export default ChatPanel;

4.4 MessageFeed – Message Thread

MessageFeed is responsible for rendering the list of messages in the conversation.

// src/crud/chat/components/MessageFeed.tsx

import React, { useRef, useEffect } from "react";

import { BsCheck2, BsCheck2All } from "react-icons/bs";
import { useChat } from "../../contexts/ChatContext";
import MessageItem from "../MessageItem";
import styles from "./MessageFeed.module.scss";

function MessageFeed() {
  const { messages } = useChat();
  const scrollRef = useRef(null);

  useEffect(() => {
    scrollRef.current?.scrollIntoView({ behavior: "auto" });
  }, [messages]);

  return (
    <div ref={scrollRef} className={styles.feed}>
      {messages.map((group) => (
        <div key={group.date} className={styles.dateGroup}>
          <div className={styles.dateDivider}>
            <span>{group.date}</span>
          </div>
          {group.messages.map((msg) => (
            <div className={styles.message}>
              {msg.text && <p>{msg.text}</p>}
              {msg.image && (
                <img
                  src={msg.image}
                  alt=""
                  style={{ maxWidth: "200px", borderRadius: 4 }}
                />
              )}
              {msg.file && (
                <a href={msg.file} target="_blank" rel="noopener noreferrer">
                  Скачать файл
                </a>
              )}
              <div style={{ fontSize: "0.8rem", opacity: 0.6 }}>
                {dayjs(msg.created_at).format("HH:mm")}
                {msg.is_read ? <BsCheck2All /> : <BsCheck2 />}
              </div>
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

export default MessageFeed;

4.5 MessageInput – Message Input Field

MessageInput allows the user to type a message and attach images.

// src/crud/chat/components/MessageInput.tsx

import React from "react";

import {
  ChangeEventHandler,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";

import { FiPaperclip } from "react-icons/fi";
import { RxPaperPlane } from "react-icons/rx";
import { Form, Button, useUrlState, Textarea } from "@devfamily/admiral";

import { useChat } from "../../model/ChatContext";

import styles from "./MessageInput.module.scss";

function MessageInput() {
  const { sendMessage } = useChat();
  const [urlState] = useUrlState();
  const { client_id } = urlState;
  const [values, setValues] = useState({});
  const textRef = useRef < HTMLTextAreaElement > null;

  useEffect(() => {
    setValues({});
    setErrors(null);
  }, [client_id]);

  const onSubmit = useCallback(
    async (e?: React.FormEvent<HTMLFormElement>) => {
      e?.preventDefault();
      const textIsEmpty = !values.text?.trim()?.length;

      sendMessage(
        {
          ...(values.image && { image: values.image }),
          ...(!textIsEmpty && { text: values.text }),
          client_id,
        },
        () => {
          setValues({ text: "" });
        },
        (err: any) => {
          if (err.errors) {
            setErrors(err.errors);
          }
        }
      );
    },
    [values, sendMessage, client_id]
  );

  const onUploadFile: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      const file = Array.from(e.target.files || [])[0];
      setValues((prev: any) => ({ ...prev, image: file }));
      e.target.value = "";
    },
    [values]
  );

  const onChange = useCallback((e) => {
    setValues((prev) => ({ ...prev, text: e.target.value }));
  }, []);

  const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if ((e.code === "Enter" || e.code === "NumpadEnter") && !e.shiftKey) {
      onSubmit();
      e.preventDefault();
    }
  }, [onSubmit]);

  return (
    <form className={styles.form} onSubmit={onSubmit}>
      <label className={styles.upload}>
        <input
          type="file"
          onChange={onUploadFile}
          className={styles.visuallyHidden}
        />
        <FiPaperclip size="24px" />
      </label>
      <Textarea
        value={values.text ?? ""}
        onChange={onChange}
        rows={1}
        onKeyDown={onKeyDown}
        placeholder="Написать сообщение..."
        ref={textRef}
        className={styles.textarea}
      />
      <Button
        view="secondary"
        type="submit"
        disabled={!values.image && !values.text?.trim().length}
        className={styles.submitBtn}
      >
        <RxPaperPlane />
      </Button>
    </form>
  );
}

export default MessageInput;

Styling

To keep the chat visually consistent with the rest of the admin panel, use Admiral’s built-in CSS variables. Example:

.chat {
  border-radius: var(--radius-m);
  border: 2px solid var(--color-bg-border);
  background-color: var(--color-bg-default);
}

.message {
  padding: var(--space-m);
  border-radius: var(--radius-s);
  background-color: var(--color-bg-default);
}

⚡All available CSS variables in Admiral can be found here: https://github.com/dev-family/admiral/tree/master/src/theme.

You can also customize things like the color scheme to match your project’s branding.

Customizing the Admin Panel UI in Admiral - dev.family

Customizing the Admin Panel UI in Admiral

For more details, check out our article

Adding Notifications Directly in ChatContext

If needed, it's easy to integrate new message notifications directly into ChatContext.

import { useNotifications } from '@devfamily/admiral'

const ChatContext = () => {
  const { showNotification } = useNotifications()
  
  useEffect(() => {
    if (!lastMessage) return
    
    if (selectedDialog?.client_id !== lastMessage.client_id) {
      showNotification({
        title: 'New message',
        message: `${lastMessage.client_name}: ${lastMessage.text || 'Image'}`,
        type: 'info',
        duration: 5000
      })
    }
  }, [lastMessage, selectedDialog, showNotification])
}

Conclusion

Building a chat inside an admin panel becomes much easier with Admiral, thanks to its thoughtful architecture and built-in hooks and components:

  1. Easy integration – no complex routing or config setup required.
  2. Flexible architecture – easily extended with custom contexts and components.
  3. Built-in features – auth, theming, and navigation hooks work out of the box.
  4. Consistent design – all components follow the global design system automatically

Admiral makes building complex admin panels feel intuitive. Whether you need a chat, a dashboard with charts, or integrations with external services – the framework gives you everything you need to ship fast and build right.

Still have questions about creating a chat in the Admiral admin panel? We're here to help!

You may also like: