Skip to main content
This guide will have you running a fully functional chat application with real-time messaging, media sharing, and WhatsApp-like UI in just a few minutes.

Prerequisites

Before you begin, ensure you have:
  • Node.js 20+ installed on your system
  • npm, yarn, or pnpm package manager
  • A code editor (VS Code recommended)

Installation

1

Install dependencies

Choose your preferred package manager:
npm install
This installs all required packages including Next.js, React, Zustand, Tailwind CSS, and UI components.
2

Start the development server

Launch the application in development mode:
npm run dev
The dev server will start with Turbopack for fast refresh.
3

Open the application

Navigate to http://localhost:3000 in your browser.You’ll see the chat interface with demo conversations already loaded.
The application uses mock data for demonstration. You’ll see several pre-populated conversations that you can interact with immediately.

Your first interaction

Let’s explore the core features:

Send a message

1

Select a conversation

Click any contact in the left sidebar to open their chat.
2

Type your message

Click the message input at the bottom and type your text. Press Enter to send (or Shift+Enter for a new line).
// The message is created with initial status
const message: Message = {
  id: generateMessageId(),
  chatId: activeChatId,
  authorId: profile.id,
  contentType: "text",
  text: "Hello from the quickstart!",
  status: "queued",
  createdAt: new Date().toISOString()
}
3

Watch the status updates

Your message will automatically progress through delivery states:
  • ⏱️ Queued → ⏰ Sending → ✓ Sent → ✓✓ Delivered → ✓✓ Read (blue)
Each transition happens with a ~450ms delay, simulating real network conditions.

Share an image

1

Open the file picker

Click the paperclip icon (📎) in the message composer, or drag and drop an image directly onto the input area.
2

Preview and send

You’ll see a thumbnail preview with a remove button (✕). Click the send button to share the image.
// Media attachments are created with preview URLs
const media: MediaAttachment = {
  id: `upload-${file.name}-${file.lastModified}`,
  type: "image",
  url: URL.createObjectURL(file),
  localObjectUrl: objectUrl,
  sizeInBytes: file.size
}
3

View in conversation

Images appear in message bubbles with rounded corners and hover effects. Click any image to preview it full-size.

Use the emoji picker

1

Open emoji palette

Click the smiley face icon (😊) to open the emoji picker powered by emoji-mart.
2

Select an emoji

Browse categories or search for emojis. Click to insert at the cursor position.
// Emojis are inserted as native Unicode characters
setText((prev) => `${prev}${emoji.native}`)

Understanding the code

Main application structure

The entry point renders the ChatApp component in app/page.tsx:
app/page.tsx
import { ChatApp } from "@/components/chat/chat-app";

export default function Home() {
  return (
    <main className="flex min-h-screen items-stretch bg-app-surface">
      <ChatApp />
    </main>
  );
}

State management with Zustand

All chat data is managed through a centralized Zustand store in lib/chat/state.ts:
lib/chat/state.ts
export const useChatStore = create<ChatStore>()(persist(
  (set, get) => ({
    chats: {},
    messages: {},
    contacts: {},
    drafts: {},
    typingIndicators: [],
    activeChatId: undefined,
    
    // Send a message and trigger status lifecycle
    sendComposerPayload: (chatId, payload) => {
      const message = createMessage(chatId, payload)
      set((state) => ({
        messages: { ...state.messages, [message.id]: message },
        chats: { 
          ...state.chats, 
          [chatId]: updateChatWithMessage(state.chats[chatId], message)
        }
      }))
      scheduleLifecycle(message.id, get, set)
      return message.id
    },
    
    // Receive incoming messages
    receiveMessage: (chatId, message) => {
      set((state) => ({
        messages: { ...state.messages, [message.id]: message },
        chats: {
          ...state.chats,
          [chatId]: {
            ...state.chats[chatId],
            unreadCount: state.activeChatId === chatId 
              ? 0 
              : state.chats[chatId].unreadCount + 1
          }
        }
      }))
    }
  }),
  { 
    name: 'chat-store',
    storage: createJSONStorage(() => localStorage)
  }
))

Chat simulator for demo

The useChatSimulator hook in hooks/use-chat-simulator.ts simulates incoming messages:
hooks/use-chat-simulator.ts
export function useChatSimulator() {
  const contacts = useChatStore((state) => state.contacts)
  const receiveMessage = useChatStore((state) => state.receiveMessage)
  const setTypingIndicator = useChatStore((state) => state.setTypingIndicator)

  useEffect(() => {
    // Send simulated messages every 18 seconds
    const interval = setInterval(() => {
      const chat = pickRandomChat()
      const contact = contacts[chat.contactId]
      
      // Show typing indicator
      setTypingIndicator(chat.id, contact.id, true)
      
      // Wait 2-4 seconds, then send message
      setTimeout(() => {
        setTypingIndicator(chat.id, contact.id, false)
        const message = createInboundTextMessage({
          chatId: chat.id,
          authorId: contact.id,
          text: pickRandomResponse()
        })
        receiveMessage(chat.id, message)
      }, 2200 + Math.random() * 1800)
    }, 18000)

    return () => clearInterval(interval)
  }, [contacts, receiveMessage, setTypingIndicator])
}

Message bubble component

Individual messages are rendered by MessageBubble in components/chat/message-bubble.tsx:
components/chat/message-bubble.tsx
export const MessageBubble = memo(({ 
  message, 
  isOutgoing,
  showAvatar,
  onMediaPreview 
}: MessageBubbleProps) => {
  const StatusIcon = statusIcon[message.status]
  const timeLabel = format(new Date(message.createdAt), "HH:mm")
  
  return (
    <div className={cn(
      "flex gap-2",
      isOutgoing ? "justify-end" : "justify-start"
    )}>
      {!isOutgoing && showAvatar && (
        <div className="h-7 w-7 rounded-full bg-secondary">
          {contactInitials}
        </div>
      )}
      
      <div className={cn(
        "rounded-3xl px-4 py-2 shadow-sm",
        isOutgoing 
          ? "rounded-br-lg bg-bubble-outgoing" 
          : "rounded-bl-lg bg-bubble-incoming"
      )}>
        {message.contentType === "image" && message.media && (
          <Image
            src={message.media.url}
            alt={message.media.caption ?? "Shared media"}
            width={message.media.width ?? 640}
            height={message.media.height ?? 360}
            className="rounded-2xl"
          />
        )}
        
        {message.text && (
          <p className="text-sm leading-snug">{message.text}</p>
        )}
        
        <footer className="mt-1 flex items-center gap-1 text-xs">
          <span>{timeLabel}</span>
          {isOutgoing && (
            <StatusIcon className={statusColor[message.status]} />
          )}
        </footer>
      </div>
    </div>
  )
})

Next steps

Now that you have the app running, explore these topics:

Installation

Learn about the full dependency tree and package configuration

State management

Deep dive into the Zustand store structure and persistence

Component library

Explore the Radix UI components and Tailwind styling

Message lifecycle

Understand how messages transition through delivery states
Pro tip: Check the browser’s localStorage to see persisted chat data. Open DevTools → Application → Local Storage → chat-store

Build docs developers (and LLMs) love