November 17, 202510 min

Real‑Time Chat with Reactions and Threads in Nuxt 4 + TypeScript + Firebase

Build a production‑ready, real‑time chat with emoji reactions and threaded replies using Nuxt 4, TypeScript, and Firebase (v10) Firestore with multi‑tab persistent cache.

Real‑Time Chat with Reactions and Threads in Nuxt 4 + TypeScript + Firebase

nuxt-firebase-realtime-chat

Build a production‑ready, real‑time chat with emoji reactions and threaded replies using Nuxt 4, TypeScript, and Firebase v10 Firestore. We’ll also enable the latest Firestore multi‑tab persistent cache for a smooth offline‑first experience.

Tags: Nuxt 4, Firebase, Firestore, Real‑time, Reactions, Threads

Time to read: 10 min

What’s new we’ll use

  • Firebase Web SDK v10 modular APIs
  • Firestore persistentLocalCache with persistentMultipleTabManager for multi‑tab offline cache (2024+)
  • Nuxt 4 + TypeScript strict for safer DX

Prerequisites

  • Basic Vue/Nuxt knowledge
  • Node.js 18 or higher
  • A Firebase project (Firestore + Authentication enabled)

1) Create the Nuxt 4 app

npx nuxi init nuxt4-realtime-chat
cd nuxt4-realtime-chat
npm install

Run it:

npm run dev

Open http://localhost:3000.

2) Install Firebase

npm i firebase

3) Configure environment and Nuxt

Create an .env (or .env.local) at project root:

NUXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NUXT_PUBLIC_FIREBASE_APP_ID=your_app_id
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id

Update nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  typescript: { strict: true },
  runtimeConfig: {
    public: {
      firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
      firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
      firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
      firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
      firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
      firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
    },
  },
})

4) Firebase plugin (client‑only) with multi‑tab persistent cache

Create plugins/firebase.client.ts:

// plugins/firebase.client.ts
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
import { initializeApp, type FirebaseApp } from 'firebase/app'
import {
  Auth,
  getAuth,
  onAuthStateChanged,
  signInAnonymously,
  updateProfile,
} from 'firebase/auth'
import {
  Firestore,
  initializeFirestore,
  persistentLocalCache,
  persistentMultipleTabManager,
} from 'firebase/firestore'

export default defineNuxtPlugin(async (nuxtApp) => {
  const cfg = useRuntimeConfig().public

  const app: FirebaseApp = initializeApp({
    apiKey: cfg.firebaseApiKey,
    authDomain: cfg.firebaseAuthDomain,
    projectId: cfg.firebaseProjectId,
    appId: cfg.firebaseAppId,
    storageBucket: cfg.firebaseStorageBucket,
    messagingSenderId: cfg.firebaseMessagingSenderId,
  })

  // New multi‑tab persistent cache
  const db: Firestore = initializeFirestore(app, {
    localCache: persistentLocalCache({
      tabManager: persistentMultipleTabManager(),
    }),
  })

  const auth: Auth = getAuth(app)

  // Ensure we always have a signed-in user (anonymous)
  await new Promise<void>((resolve) => {
    const unsub = onAuthStateChanged(auth, async (user) => {
      if (!user) {
        await signInAnonymously(auth)
      } else if (!user.displayName) {
        // Assign a friendly guest name once
        const name = 'Guest-' + Math.random().toString(36).slice(2, 7)
        await updateProfile(user, { displayName: name })
      }
      unsub()
      resolve()
    })
  })

  nuxtApp.provide('firebaseApp', app)
  nuxtApp.provide('db', db)
  nuxtApp.provide('auth', auth)
})

declare module '#app' {
  interface NuxtApp {
    $firebaseApp: FirebaseApp
    $db: Firestore
    $auth: Auth
  }
}
declare module 'vue' {
  interface ComponentCustomProperties {
    $firebaseApp: FirebaseApp
    $db: Firestore
    $auth: Auth
  }
}

5) TypeScript models

Create types/chat.ts:

// types/chat.ts
import type { Timestamp, FieldValue } from 'firebase/firestore'

export type ChatMessage = {
  id?: string
  text: string
  userId: string
  displayName: string
  createdAt: Timestamp | FieldValue
  threadCount?: number
}

export type ThreadReply = {
  id?: string
  text: string
  userId: string
  displayName: string
  createdAt: Timestamp | FieldValue
}

export type Reaction = {
  // Stored as {emojiKey}_{uid}
  emoji: string
  uid: string
  createdAt: Timestamp | FieldValue
}

6) Simple auth composable

Create composables/useAuth.ts:

// composables/useAuth.ts
import { onAuthStateChanged, type User } from 'firebase/auth'

export function useAuth() {
  const nuxt = useNuxtApp()
  const user = useState<User | null>('user', () => nuxt.$auth.currentUser)

  onMounted(() => {
    const unsub = onAuthStateChanged(nuxt.$auth, (u) => (user.value = u))
    onBeforeUnmount(unsub)
  })

  return { user }
}

7) Utility for emoji IDs

We’ll ensure one reaction per emoji per user using deterministic document IDs.

Create utils/emoji.ts:

// utils/emoji.ts
export function emojiKey(emoji: string): string {
  // Convert grapheme(s) to hex code points joined by '-'
  return Array.from(emoji).map((c) => c.codePointAt(0)!.toString(16)).join('-')
}

8) Chat components

We’ll use a single room called “general” for simplicity.

Create pages/index.vue:

<script setup lang="ts">
</script>

<template>
  <div class="page">
    <h1>Nuxt + Firebase Real‑Time Chat</h1>
    <ChatRoom room-id="general" />
  </div>
</template>

<style scoped>
.page {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem 1rem;
}
</style>

Create components/ChatRoom.vue:

<script setup lang="ts">
import { collection, addDoc, serverTimestamp, query, orderBy, onSnapshot, doc, updateDoc, increment } from 'firebase/firestore'
import { useAuth } from '@/composables/useAuth'
import type { ChatMessage } from '@/types/chat'
import ThreadPanel from './ThreadPanel.vue'
import ReactionBar from './ReactionBar.vue'

const props = defineProps<{ roomId: string }>()

const nuxt = useNuxtApp()
const { user } = useAuth()

const messages = ref<ChatMessage[]>([])
const input = ref('')
const activeThreadFor = ref<string | null>(null)

const messagesRef = computed(() =>
  collection(nuxt.$db, 'rooms', props.roomId, 'messages')
)

onMounted(() => {
  const q = query(messagesRef.value, orderBy('createdAt', 'asc'))
  const unsub = onSnapshot(q, (snap) => {
    messages.value = snap.docs.map((d) => ({ id: d.id, ...(d.data() as ChatMessage) }))
  })
  onBeforeUnmount(unsub)
})

async function sendMessage() {
  if (!user.value || !input.value.trim()) return
  const payload: ChatMessage = {
    text: input.value.trim(),
    userId: user.value.uid,
    displayName: user.value.displayName || 'Guest',
    createdAt: serverTimestamp(),
    threadCount: 0,
  }
  await addDoc(messagesRef.value, payload)
  input.value = ''
}

function openThread(messageId: string) {
  activeThreadFor.value = messageId
}

function closeThread() {
  activeThreadFor.value = null
}

// When a reply is added in the thread panel, bump threadCount on the parent message
async function onThreadReplyAdded(messageId: string) {
  const parentDoc = doc(nuxt.$db, 'rooms', props.roomId, 'messages', messageId)
  await updateDoc(parentDoc, { threadCount: increment(1) })
}
</script>

<template>
  <div class="chat">
    <div class="messages">
      <div v-for="m in messages" :key="m.id" class="message">
        <div class="meta">
          <strong>{{ m.displayName }}</strong>
          <span class="dot">•</span>
          <span class="time">
            <!-- serverTimestamp() may be null locally until resolved -->
            {{ (m as any).createdAt?.toDate?.().toLocaleTimeString?.() ?? '...' }}
          </span>
        </div>
        <div class="text">{{ m.text }}</div>

        <ReactionBar
          v-if="m.id"
          :room-id="props.roomId"
          :message-id="m.id"
        />

        <button v-if="m.id" class="thread-btn" @click="openThread(m.id)">
          {{ m.threadCount || 0 }} replies
        </button>
      </div>
    </div>

    <form class="input" @submit.prevent="sendMessage">
      <input
        v-model="input"
        placeholder="Type a message"
        autocomplete="off"
      />
      <button type="submit">Send</button>
    </form>

    <ThreadPanel
      v-if="activeThreadFor"
      :room-id="props.roomId"
      :message-id="activeThreadFor"
      @close="closeThread"
      @reply-added="onThreadReplyAdded"
    />
  </div>
</template>

<style scoped>
.chat {
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  overflow: hidden;
  background: #fff;
}
.messages {
  padding: 1rem;
  max-height: 60vh;
  overflow: auto;
}
.message + .message {
  border-top: 1px dashed #eee;
  margin-top: .75rem;
  padding-top: .75rem;
}
.meta {
  color: #6b7280;
  font-size: 0.9rem;
  display: flex;
  align-items: center;
  gap: .4rem;
}
.dot {
  opacity: .5;
}
.text {
  margin: .25rem 0 .5rem;
  white-space: pre-wrap;
}
.thread-btn {
  background: transparent;
  border: none;
  color: #2563eb;
  cursor: pointer;
  padding: 0;
}
.input {
  display: flex;
  gap: .5rem;
  padding: .75rem;
  border-top: 1px solid #e5e7eb;
  background: #f9fafb;
}
.input input {
  flex: 1;
  padding: .6rem .8rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.input button {
  padding: .6rem .9rem;
  background: #111827;
  color: #fff;
  border: none;
  border-radius: 8px;
}
</style>

Create components/ReactionBar.vue:

<script setup lang="ts">
import { collection, doc, onSnapshot, serverTimestamp, setDoc, deleteDoc } from 'firebase/firestore'
import { useAuth } from '@/composables/useAuth'
import { emojiKey } from '@/utils/emoji'

const props = defineProps<{
  roomId: string
  messageId: string
}>()

const nuxt = useNuxtApp()
const { user } = useAuth()

const EMOJIS = ['👍', '❤️', '😂', '🎉', '😮', '😢']

// Counts per emoji and which the current user has added
const counts = ref<Record<string, number>>({})
const mine = ref<Set<string>>(new Set())

const reactionsRef = computed(() =>
  collection(nuxt.$db, 'rooms', props.roomId, 'messages', props.messageId, 'reactions')
)

onMounted(() => {
  const unsub = onSnapshot(reactionsRef.value, (snap) => {
    const map: Record<string, number> = {}
    const mineSet = new Set<string>()
    for (const e of EMOJIS) map[e] = 0

    snap.forEach((d) => {
      const data = d.data() as { emoji: string; uid: string }
      if (EMOJIS.includes(data.emoji)) {
        map[data.emoji] = (map[data.emoji] || 0) + 1
        if (user.value && data.uid === user.value.uid) {
          mineSet.add(data.emoji)
        }
      }
    })

    counts.value = map
    mine.value = mineSet
  })
  onBeforeUnmount(unsub)
})

async function toggle(emoji: string) {
  if (!user.value) return
  const uid = user.value.uid
  const id = `${emojiKey(emoji)}_${uid}`
  const ref = doc(reactionsRef.value, id)

  if (mine.value.has(emoji)) {
    await deleteDoc(ref)
  } else {
    await setDoc(ref, {
      emoji,
      uid,
      createdAt: serverTimestamp(),
    })
  }
}
</script>

<template>
  <div class="reactions">
    <button
      v-for="e in EMOJIS"
      :key="e"
      class="reaction"
      :class="{ mine: mine.has(e) }"
      @click="toggle(e)"
      :aria-pressed="mine.has(e)"
    >
      <span class="emoji">{{ e }}</span>
      <span class="count">{{ counts[e] || 0 }}</span>
    </button>
  </div>
</template>

<style scoped>
.reactions {
  display: flex;
  gap: .4rem;
  margin-bottom: .5rem;
}
.reaction {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  padding: .15rem .4rem;
  border-radius: 999px;
  border: 1px solid #e5e7eb;
  background: #fff;
  cursor: pointer;
  font-size: .95rem;
}
.reaction.mine {
  border-color: #2563eb;
  background: #eff6ff;
}
.emoji { line-height: 1; }
.count { color: #6b7280; font-size: .85rem; }
</style>

Create components/ThreadPanel.vue:

<script setup lang="ts">
import { collection, addDoc, onSnapshot, orderBy, query, serverTimestamp } from 'firebase/firestore'
import type { ThreadReply } from '@/types/chat'
import { useAuth } from '@/composables/useAuth'

const props = defineProps<{
  roomId: string
  messageId: string
}>()

const emit = defineEmits<{
  (e: 'close'): void
  (e: 'reply-added', messageId: string): void
}>()

const nuxt = useNuxtApp()
const { user } = useAuth()

const replies = ref<ThreadReply[]>([])
const input = ref('')

const repliesRef = computed(() =>
  collection(nuxt.$db, 'rooms', props.roomId, 'messages', props.messageId, 'replies')
)

onMounted(() => {
  const q = query(repliesRef.value, orderBy('createdAt', 'asc'))
  const unsub = onSnapshot(q, (snap) => {
    replies.value = snap.docs.map((d) => ({ id: d.id, ...(d.data() as ThreadReply) }))
  })
  onBeforeUnmount(unsub)
})

async function sendReply() {
  if (!user.value || !input.value.trim()) return
  await addDoc(repliesRef.value, {
    text: input.value.trim(),
    userId: user.value.uid,
    displayName: user.value.displayName || 'Guest',
    createdAt: serverTimestamp(),
  } satisfies ThreadReply)
  input.value = ''
  emit('reply-added', props.messageId)
}
</script>

<template>
  <div class="panel">
    <div class="header">
      <h3>Thread</h3>
      <button class="close" @click="$emit('close')">✕</button>
    </div>

    <div class="replies">
      <div v-for="r in replies" :key="r.id" class="reply">
        <div class="meta">
          <strong>{{ r.displayName }}</strong>
          <span class="dot">•</span>
          <span class="time">
            {{ (r as any).createdAt?.toDate?.().toLocaleTimeString?.() ?? '...' }}
          </span>
        </div>
        <div class="text">{{ r.text }}</div>
      </div>
    </div>

    <form class="input" @submit.prevent="sendReply">
      <input v-model="input" placeholder="Write a reply" />
      <button type="submit">Reply</button>
    </form>
  </div>
</template>

<style scoped>
.panel {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  top: 1rem;
  width: min(420px, 95vw);
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0,0,0,.07);
}
.header {
  display: flex; align-items: center; justify-content: space-between;
  padding: .8rem 1rem;
  border-bottom: 1px solid #e5e7eb;
}
.close {
  background: transparent; border: none; cursor: pointer; font-size: 1.1rem;
}
.replies {
  padding: 1rem;
  overflow: auto;
  flex: 1;
}
.reply + .reply {
  border-top: 1px dashed #eee;
  margin-top: .75rem;
  padding-top: .75rem;
}
.meta {
  color: #6b7280;
  font-size: 0.9rem;
  display: flex;
  align-items: center;
  gap: .4rem;
}
.dot { opacity: .5; }
.text { margin-top: .25rem; white-space: pre-wrap; }
.input {
  display: flex;
  gap: .5rem;
  padding: .75rem;
  border-top: 1px solid #e5e7eb;
  background: #f9fafb;
}
.input input {
  flex: 1;
  padding: .6rem .8rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.input button {
  padding: .6rem .9rem;
  background: #2563eb;
  color: #fff;
  border: none;
  border-radius: 8px;
}
</style>

9) Firestore security rules

Open Firebase Console > Firestore > Rules, and use:

// Firestore security rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    function isAuthed() {
      return request.auth != null;
    }

    match /rooms/{roomId} {
      allow read: if true;
      // Disallow creating rooms via client for simplicity
      allow create, update, delete: if false;

      match /messages/{messageId} {
        allow read: if true;

        // Users can post messages bound to themselves and text length <= 2000
        allow create: if isAuthed()
          && request.resource.data.userId == request.auth.uid
          && request.resource.data.text is string
          && request.resource.data.text.size() > 0
          && request.resource.data.text.size() <= 2000;

        // Allow updating threadCount only (via increment resolves to int)
        allow update: if isAuthed()
          && request.resource.data.diff(resource.data).changedKeys().hasOnly(['threadCount'])
          && request.resource.data.threadCount is int;

        // Replies (threaded)
        match /replies/{replyId} {
          allow read: if true;
          allow create: if isAuthed()
            && request.resource.data.userId == request.auth.uid
            && request.resource.data.text is string
            && request.resource.data.text.size() > 0
            && request.resource.data.text.size() <= 2000;
          allow update, delete: if false;
        }

        // Reactions: document id is {emojiKey}_{uid}
        match /reactions/{reactionId} {
          allow read: if true;

          // One reaction per emoji per user is enforced by id design
          allow create: if isAuthed()
            && request.resource.id.matches('^[a-f0-9-]+_' + request.auth.uid + '$')
            && request.resource.data.emoji is string
            && request.resource.data.uid == request.auth.uid;

          allow delete: if isAuthed()
            && resource.id.matches('^[a-f0-9-]+_' + request.auth.uid + '$');

          allow update: if false;
        }
      }
    }
  }
}

Notes:

  • We enforce reaction ownership via the document ID pattern.
  • We restrict message updates to threadCount only (so client cannot edit message text).

10) Seed a room (optional)

Create a document rooms/general manually in Firestore to represent the default room. The messages subcollection will be created on first write.

11) Run and test

npm run dev
  • Open two browser tabs (or devices).
  • Send messages in one tab; they appear instantly in the other.
  • Add emoji reactions; counts update in real‑time. Each user can toggle each emoji once.
  • Open a thread on any message, post replies, and watch threadCount increase.

12) Production tips

  • Enable Firestore composite indexes only if you add filters; current queries use orderBy only.
  • Add Firebase App Check for abuse protection (reCAPTCHA Enterprise).
  • Gate anonymous sign‑in with daily usage quotas if you expect heavy traffic.
  • For larger rooms, paginate messages with startAfter and limit.

Conclusion

You built a Nuxt 4 + TypeScript real‑time chat using Firebase v10 Firestore with:

  • Live messages
  • Emoji reactions (one per emoji per user)
  • Threaded replies
  • Multi‑tab persistent cache for an offline‑first feel

This foundation is production‑ready and extendable: message editing, moderation, typing indicators, presence (RTDB), and file uploads are natural next steps.