Build a Real-Time Chat App with Vue 3

Last Modified: December 31, 2024

Build a full-featured real-time chat application using Vue 3 and Firebase. We'll create a modern, responsive chat interface with real-time updates. 🚀

Project Setup 🎯

First, let's create a new Vue 3 project and install dependencies.

npm create vue@latest chat-app
cd chat-app
npm install firebase@latest pinia@latest vue-router@4

Firebase Configuration 🔥

// src/firebase/config.js
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: 'your-api-key',
  authDomain: 'your-auth-domain',
  projectId: 'your-project-id',
  storageBucket: 'your-storage-bucket',
  messagingSenderId: 'your-messaging-sender-id',
  appId: 'your-app-id'
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const auth = getAuth(app);

export { db, auth };

Authentication Setup 🔒

// src/composables/useAuth.js
import { ref } from 'vue';
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signOut as firebaseSignOut
} from 'firebase/auth';
import { auth } from '../firebase/config';

export function useAuth() {
  const user = ref(null);
  const error = ref(null);

  async function signup(email, password) {
    try {
      const res = await createUserWithEmailAndPassword(
        auth,
        email,
        password
      );
      user.value = res.user;
      error.value = null;
    } catch (err) {
      error.value = err.message;
    }
  }

  async function login(email, password) {
    try {
      const res = await signInWithEmailAndPassword(
        auth,
        email,
        password
      );
      user.value = res.user;
      error.value = null;
    } catch (err) {
      error.value = err.message;
    }
  }

  async function signOut() {
    try {
      await firebaseSignOut(auth);
      user.value = null;
    } catch (err) {
      error.value = err.message;
    }
  }

  return { user, error, signup, login, signOut };
}

Chat Store with Pinia 📦

// src/stores/chat.js
import { defineStore } from 'pinia';
import {
  collection,
  addDoc,
  query,
  orderBy,
  onSnapshot
} from 'firebase/firestore';
import { db } from '../firebase/config';

export const useChatStore = defineStore('chat', {
  state: () => ({
    messages: [],
    loading: false,
    error: null
  }),

  actions: {
    async sendMessage(messageText, userId) {
      try {
        await addDoc(collection(db, 'messages'), {
          text: messageText,
          userId,
          createdAt: new Date().toISOString()
        });
      } catch (error) {
        this.error = error.message;
      }
    },

    subscribeToMessages() {
      const q = query(
        collection(db, 'messages'),
        orderBy('createdAt')
      );

      return onSnapshot(q, (snapshot) => {
        this.messages = snapshot.docs.map(doc => ({
          id: doc.id,
          ...doc.data()
        }));
      });
    }
  }
});

Chat Component 💬

<!-- src/components/ChatRoom.vue -->
<template>
  <div class="chat-container">
    <div class="messages" ref="messagesContainer">
      <div
        v-for="message in messages"
        :key="message.id"
        :class="['message', {
          'message--own': message.userId === user?.uid
        }]"
      >
        <div class="message__content">
          {{ message.text }}
        </div>
        <div class="message__time">
          {{ formatTime(message.createdAt) }}
        </div>
      </div>
    </div>

    <form @submit.prevent="sendMessage" class="message-form">
      <input
        v-model="newMessage"
        type="text"
        placeholder="Type a message..."
        :disabled="!user"
      />
      <button
        type="submit"
        :disabled="!newMessage.trim() || !user"
      >
        Send
      </button>
    </form>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useChatStore } from '../stores/chat';
import { useAuth } from '../composables/useAuth';
import { formatTime } from '../utils/datetime';

const chatStore = useChatStore();
const { messages } = storeToRefs(chatStore);
const { user } = useAuth();

const newMessage = ref('');
const messagesContainer = ref(null);
let unsubscribe = null;

onMounted(() => {
  unsubscribe = chatStore.subscribeToMessages();
});

onUnmounted(() => {
  if (unsubscribe) {
    unsubscribe();
  }
});

watch(messages, () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = 
        messagesContainer.value.scrollHeight;
    }
  });
});

async function sendMessage() {
  if (!newMessage.value.trim() || !user.value) return;

  await chatStore.sendMessage(
    newMessage.value,
    user.value.uid
  );
  newMessage.value = '';
}
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
  padding: 1rem;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.message {
  max-width: 70%;
  padding: 0.5rem 1rem;
  border-radius: 1rem;
  background: #f1f1f1;
}

.message--own {
  align-self: flex-end;
  background: #0084ff;
  color: white;
}

.message__content {
  margin-bottom: 0.25rem;
}

.message__time {
  font-size: 0.75rem;
  opacity: 0.7;
}

.message-form {
  display: flex;
  gap: 1rem;
  padding: 1rem;
  background: white;
  border-top: 1px solid #eee;
}

input {
  flex: 1;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 0.5rem;
}

button {
  padding: 0.5rem 1rem;
  background: #0084ff;
  color: white;
  border: none;
  border-radius: 0.5rem;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

Router Setup 🛣️

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { auth } from '../firebase/config';

const routes = [
  {
    path: '/',
    name: 'Chat',
    component: () => import('../views/ChatView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/LoginView.vue')
  },
  {
    path: '/signup',
    name: 'Signup',
    component: () => import('../views/SignupView.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

router.beforeEach((to, from, next) => {
  const requiresAuth = to.matched.some(
    record => record.meta.requiresAuth
  );

  if (requiresAuth && !auth.currentUser) {
    next('/login');
  } else {
    next();
  }
});

export default router;

User Interface Components 🎨

Login Form

<!-- src/components/LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="auth-form">
    <h2>Login</h2>

    <div class="form-group">
      <label for="email">Email</label>
      <input
        id="email"
        v-model="email"
        type="email"
        required
      />
    </div>

    <div class="form-group">
      <label for="password">Password</label>
      <input
        id="password"
        v-model="password"
        type="password"
        required
      />
    </div>

    <div v-if="error" class="error">
      {{ error }}
    </div>

    <button type="submit" :disabled="loading">
      {{ loading ? 'Loading...' : 'Login' }}
    </button>
  </form>
</template>

<script setup>
import { ref } from 'vue';
import { useAuth } from '../composables/useAuth';
import { useRouter } from 'vue-router';

const email = ref('');
const password = ref('');
const loading = ref(false);
const { login, error } = useAuth();
const router = useRouter();

async function handleSubmit() {
  loading.value = true;
  await login(email.value, password.value);
  if (!error.value) {
    router.push('/');
  }
  loading.value = false;
}
</script>

Real-Time Features 🔄

Online Status

// src/composables/useOnlineStatus.js
import { ref, onMounted, onUnmounted } from 'vue';
import { db, auth } from '../firebase/config';
import {
  doc,
  setDoc,
  serverTimestamp,
  onDisconnect
} from 'firebase/firestore';

export function useOnlineStatus() {
  const isOnline = ref(true);

  onMounted(() => {
    if (auth.currentUser) {
      const userStatusRef = doc(
        db,
        'status',
        auth.currentUser.uid
      );

      setDoc(userStatusRef, {
        state: 'online',
        lastChanged: serverTimestamp()
      });

      onDisconnect(userStatusRef).set({
        state: 'offline',
        lastChanged: serverTimestamp()
      });
    }
  });

  return { isOnline };
}

Deployment 🚀

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  build: {
    target: 'esnext',
    minify: 'terser'
  }
});

Additional Features 🌟

  1. Typing indicators
  2. Read receipts
  3. File sharing
  4. User profiles
  5. Group chats

Best Practices 📝

  1. Use Composition API for better code organization
  2. Implement proper error handling
  3. Add loading states
  4. Optimize performance
  5. Secure Firebase rules
  6. Add proper validation
  7. Handle offline support
  8. Implement proper testing
  9. Use proper TypeScript types
  10. Follow Vue style guide

Additional Resources

Building a real-time chat application with Vue 3 and Firebase provides a great foundation for understanding modern web development concepts. This implementation can be extended with additional features based on your specific needs.