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. 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.