Live Trivia Quiz and Leaderboard using Nuxt 4 + TypeScript + Firebase

Build a real-time trivia quiz with a live leaderboard using Nuxt 4 + TypeScript + Firebase. We’ll use:
- Firestore real-time listeners
- Firebase Auth (Anonymous)
- App Check with reCAPTCHA Enterprise (modern, production-ready protection)
- Cloud Functions v2 for secure server-side scoring
- Firestore’s aggregate count() for live player counts
Tags: Nuxt 4, Firebase, Firestore, Functions v2, App Check
Time to read: 12 min
Prerequisites
- Node.js 18+ installed
- A Firebase project (with Firestore + Auth enabled)
- Basic familiarity with Nuxt 3/4 and TypeScript
What We’ll Build
- Join screen: users pick a nickname and join a game by code.
- Host creates a game and launches questions.
- Players answer in real time; leaderboard updates instantly.
- Scores are computed securely on the server using Cloud Functions v2 when the host reveals the answer.
- App Check protects your backend from abuse with reCAPTCHA Enterprise.
1) Create the Nuxt 4 app
npx nuxi@latest init nuxt4-trivia
cd nuxt4-trivia
npm install
Run and verify:
npm run dev
2) Install Firebase (Web v10+ modular SDK)
npm i firebase
npm i -D @types/node
3) Configure Nuxt runtime config (public Firebase env)
Create a .env file with your Firebase Web app config from the Firebase Console (Project Settings > General > Your apps).
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_MEASUREMENT_ID=G-XXXXXXX
# Optional but recommended for production
NUXT_PUBLIC_RECAPTCHA_ENTERPRISE_SITE_KEY=YOUR_RECAPTCHA_ENTERPRISE_SITE_KEY
Update nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
future: { typescriptBundlerResolution: true },
typescript: { strict: true },
runtimeConfig: {
public: {
firebase: {
apiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
measurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
},
recaptchaEnterpriseSiteKey: process.env.NUXT_PUBLIC_RECAPTCHA_ENTERPRISE_SITE_KEY || '',
},
},
})
4) Client-only Firebase plugin with App Check and Firestore persistent cache 2.0
Create plugins/firebase.client.ts:
// plugins/firebase.client.ts
import { defineNuxtPlugin } from '#app'
import { initializeApp, getApps, getApp } from 'firebase/app'
import { getAuth, signInAnonymously, onAuthStateChanged } from 'firebase/auth'
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
} from 'firebase/firestore'
import { initializeAppCheck, ReCaptchaEnterpriseProvider } from 'firebase/app-check'
type FirebaseServices = {
app: ReturnType<typeof initializeApp>
auth: ReturnType<typeof getAuth>
db: ReturnType<typeof initializeFirestore>
}
let services: FirebaseServices | null = null
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const publicFirebase = config.public.firebase
const app = getApps().length
? getApp()
: initializeApp({
apiKey: publicFirebase.apiKey,
authDomain: publicFirebase.authDomain,
projectId: publicFirebase.projectId,
appId: publicFirebase.appId,
measurementId: publicFirebase.measurementId,
})
// Firestore persistent cache across tabs (modern local cache v2)
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
}),
})
const auth = getAuth(app)
// App Check with reCAPTCHA Enterprise (recommended for production)
const siteKey = config.public.recaptchaEnterpriseSiteKey
if (siteKey) {
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(siteKey),
isTokenAutoRefreshEnabled: true,
})
}
// Ensure anonymous sign-in to secure rules while keeping friction low.
onAuthStateChanged(auth, (user) => {
if (!user) {
void signInAnonymously(auth)
}
})
services = { app, auth, db }
})
export const useFirebase = () => {
if (!services) throw new Error('Firebase not initialized')
return services
}
This plugin:
- Initializes Firebase only on the client.
- Enables Firestore’s persistent cache for offline-friendly, multi-tab sync.
- Uses App Check with reCAPTCHA Enterprise (if you provided a site key).
- Ensures the user is anonymously authenticated to pass security rules.
5) Firestore data model
We’ll structure our data like this:
- games/{gameId}
- hostId, createdAt, status
- state/current
- phase: 'lobby' | 'question' | 'reveal' | 'ended'
- questionIndex: number
- currentQuestionId: string
- questionStartAt: Timestamp
- questionDuration: number (seconds)
- players/{playerId}
- name, score, joinedAt, present
- questions/{questionId}
- text, options: string, correct: string
- answers/{playerId}
- answer, at, name
- tallies/{questionId}
- doneAt, awarded
6) Secure Firestore rules
In the Firebase Console > Firestore Database > Rules (or firestore.rules):
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /games/{gameId} {
allow read: if true;
// Create a game: caller becomes the host.
allow create: if request.auth != null
&& request.resource.data.hostId == request.auth.uid;
// Update/delete game only by host
allow update, delete: if request.auth != null
&& resource.data.hostId == request.auth.uid;
match /state/{stateId} {
allow read: if true;
// Only host can modify the state
allow write: if request.auth != null
&& get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid;
}
match /players/{playerId} {
allow read: if true;
// Player creates own doc, score must start at 0
allow create: if request.auth != null
&& request.auth.uid == playerId
&& request.resource.data.score == 0;
// Player can update their own presence/name but not score
allow update: if request.auth != null
&& request.auth.uid == playerId
&& request.resource.data.score == resource.data.score;
}
match /questions/{questionId} {
allow read: if true;
// Only host can write questions
allow write: if request.auth != null
&& get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid;
match /answers/{playerId} {
// Player and host can read answers
allow read: if request.auth != null
&& (playerId == request.auth.uid
|| get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid);
// Player can create exactly one answer doc for this question
allow create: if request.auth != null
&& request.auth.uid == playerId
&& !exists(/databases/$(database)/documents/games/$(gameId)/questions/$(questionId)/answers/$(playerId));
// No updates/deletes to answers
allow update, delete: if false;
}
}
match /tallies/{questionId} {
// Read-only for clients; written by Cloud Functions
allow read: if true;
allow write: if false;
}
}
}
}
Publish rules.
7) Cloud Functions v2: award points on reveal
We tally scores on the server to prevent cheating. When the host switches the game phase from "question" to "reveal", this function:
- Finds all correct answers
- Increments corresponding player scores
- Creates a “tally” doc to keep it idempotent
Initialize Functions:
npm i -g firebase-tools
firebase login
firebase init functions
# Choose TypeScript, Node 20, ESLint as you prefer, and Firestore triggers
Replace functions/src/index.ts with:
// functions/src/index.ts
import { onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { initializeApp } from 'firebase-admin/app'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
initializeApp()
const db = getFirestore()
export const tallyOnReveal = onDocumentUpdated('games/{gameId}/state/current', async (event) => {
const before = event.data?.before?.data() as any
const after = event.data?.after?.data() as any
if (!after) return
const wasQuestion = before?.phase === 'question'
const isReveal = after?.phase === 'reveal'
const questionId = after?.currentQuestionId as string | undefined
const gameId = event.params.gameId as string
if (!wasQuestion || !isReveal || !questionId) return
// Idempotency: if tally exists, do nothing
const tallyRef = db.doc(`games/${gameId}/tallies/${questionId}`)
const existing = await tallyRef.get()
if (existing.exists) return
const questionRef = db.doc(`games/${gameId}/questions/${questionId}`)
const questionSnap = await questionRef.get()
if (!questionSnap.exists) return
const correct = questionSnap.get('correct') as string
const answersRef = db.collection(`games/${gameId}/questions/${questionId}/answers`)
const correctSnap = await answersRef.where('answer', '==', correct).get()
let writes = 0
let batch = db.batch()
for (const doc of correctSnap.docs) {
const playerId = doc.id
const playerRef = db.doc(`games/${gameId}/players/${playerId}`)
batch.update(playerRef, { score: FieldValue.increment(1) })
writes++
if (writes === 400) {
await batch.commit()
batch = db.batch()
writes = 0
}
}
if (writes > 0) {
await batch.commit()
}
await tallyRef.set({ doneAt: FieldValue.serverTimestamp(), awarded: correctSnap.size })
})
Deploy Functions:
cd functions
npm run build
firebase deploy --only functions
8) Sample questions (local seed)
Create assets/questions.json:
[
{
"id": "q1",
"text": "Which planet is known as the Red Planet?",
"options": ["Earth", "Mars", "Jupiter", "Venus"],
"correct": "Mars"
},
{
"id": "q2",
"text": "What is the capital of Japan?",
"options": ["Seoul", "Beijing", "Tokyo", "Osaka"],
"correct": "Tokyo"
},
{
"id": "q3",
"text": "What does HTTP stand for?",
"options": ["HyperText Transfer Protocol", "High Transfer Text Protocol", "Hyperlink Transfer Process", "Host Transfer Text Protocol"],
"correct": "HyperText Transfer Protocol"
}
]
9) Create/Join UI (index page)
- Host can create a new game and seed questions.
- Players join an existing game with a nickname and code.
pages/index.vue:
<template>
<div class="container">
<h1>Nuxt + Firebase Live Trivia</h1>
<section class="host">
<h2>Host a game</h2>
<button @click="createGame" :disabled="creating">{{ creating ? 'Creating...' : 'Create Game' }}</button>
<p v-if="hostGameId">Game created: <strong>{{ hostGameId }}</strong></p>
</section>
<section class="join">
<h2>Join a game</h2>
<form @submit.prevent="joinGame">
<input v-model.trim="nickname" placeholder="Your nickname" required />
<input v-model.trim="gameId" placeholder="Game ID (e.g. ABC123)" required />
<button type="submit">Join</button>
</form>
<p v-if="playersCount !== null">Players currently in game: {{ playersCount }}</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useFirebase } from '~/plugins/firebase.client'
import {
doc, setDoc, serverTimestamp, collection, addDoc,
onSnapshot, query, orderBy, limit, getDocs, writeBatch,
getAggregateFromServer, count as aggCount
} from 'firebase/firestore'
type Question = {
id: string
text: string
options: string[]
correct: string
}
const { db, auth } = useFirebase()
const nickname = ref('')
const gameId = ref('')
const creating = ref(false)
const hostGameId = ref<string | null>(null)
const playersCount = ref<number | null>(null)
async function createGame() {
creating.value = true
try {
const uid = auth.currentUser?.uid
if (!uid) throw new Error('Not signed in')
// Simple random code for gameId
const newGameId = Math.random().toString(36).slice(2, 8).toUpperCase()
const gameRef = doc(db, 'games', newGameId)
await setDoc(gameRef, {
hostId: uid,
createdAt: serverTimestamp(),
status: 'lobby',
}, { merge: true })
// Initialize state
await setDoc(doc(db, 'games', newGameId, 'state', 'current'), {
phase: 'lobby',
questionIndex: -1,
currentQuestionId: null,
questionStartAt: null,
questionDuration: 15
})
// Seed questions from local json
const questions: Question[] = (await import('~/assets/questions.json')).default
const batch = writeBatch(db)
for (const q of questions) {
const qRef = doc(db, 'games', newGameId, 'questions', q.id)
batch.set(qRef, q)
}
await batch.commit()
hostGameId.value = newGameId
} catch (e) {
console.error(e)
alert('Failed to create game')
} finally {
creating.value = false
}
}
async function joinGame() {
const gid = gameId.value.trim().toUpperCase()
const name = nickname.value.trim()
if (!gid || !name) return
const uid = auth.currentUser?.uid
if (!uid) {
alert('Not signed in yet, please try again in a second.')
return
}
const playerRef = doc(db, 'games', gid, 'players', uid)
await setDoc(playerRef, {
name,
score: 0,
joinedAt: serverTimestamp(),
present: true,
}, { merge: true })
await navigateTo(`/game/${gid}`)
}
let unsubPlayers: (() => void) | null = null
onMounted(() => {
// Show count of players for the entered game id (when gameId changes)
// We’ll refresh aggregate on blur/join click to avoid excessive calls.
})
onUnmounted(() => {
if (unsubPlayers) unsubPlayers()
})
watch(gameId, async (val) => {
playersCount.value = null
const gid = val.trim().toUpperCase()
if (!gid) return
try {
// Use Firestore aggregate count() — server-side accurate count
const qPlayers = collection(db, 'games', gid, 'players')
const snap = await getAggregateFromServer(qPlayers, { players: aggCount() })
playersCount.value = snap.data().players as number
} catch {
playersCount.value = null
}
})
</script>
<style scoped>
.container { max-width: 720px; margin: 2rem auto; padding: 1rem; }
section { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
input { margin-right: 0.5rem; padding: 0.5rem; }
button { padding: 0.5rem 1rem; }
</style>
10) Host controls and game flow
The host can:
- Start the game (go to first question)
- Reveal the answer (triggers server tally)
- Next question
- End game
pages/host/[id].vue:
<template>
<div class="wrap">
<h1>Host Controls - {{ gid }}</h1>
<div v-if="!isHost">You are not the host of this game.</div>
<div v-else>
<div class="controls">
<button @click="startGame" :disabled="busy || (state?.phase !== 'lobby')">Start</button>
<button @click="reveal" :disabled="busy || (state?.phase !== 'question')">Reveal</button>
<button @click="next" :disabled="busy || !(state?.phase === 'reveal')">Next</button>
<button @click="endGame" :disabled="busy || (state?.phase === 'ended')">End</button>
</div>
<div class="state">
<p>Phase: {{ state?.phase }}</p>
<p>Question index: {{ state?.questionIndex }}</p>
<p>Current question: {{ state?.currentQuestionId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useFirebase } from '~/plugins/firebase.client'
import { doc, onSnapshot, getDoc, setDoc, updateDoc, serverTimestamp, collection, getDocs, query, orderBy } from 'firebase/firestore'
const { db, auth } = useFirebase()
const route = useRoute()
const gid = computed(() => String(route.params.id).toUpperCase())
const state = ref<any | null>(null)
const game = ref<any | null>(null)
const busy = ref(false)
const isHost = ref(false)
let unsubState: (() => void) | null = null
onMounted(async () => {
const gameRef = doc(db, 'games', gid.value)
const snap = await getDoc(gameRef)
game.value = snap.data()
isHost.value = !!auth.currentUser && game.value?.hostId === auth.currentUser?.uid
const stateRef = doc(db, 'games', gid.value, 'state', 'current')
unsubState = onSnapshot(stateRef, (s) => {
state.value = s.data()
})
})
onUnmounted(() => {
if (unsubState) unsubState()
})
async function startGame() {
if (!isHost.value) return
busy.value = true
try {
// Get first question
const qRef = collection(db, 'games', gid.value, 'questions')
const qSnap = await getDocs(query(qRef, orderBy('id')))
const first = qSnap.docs[0]
if (!first) throw new Error('No questions')
await setDoc(doc(db, 'games', gid.value, 'state', 'current'), {
phase: 'question',
questionIndex: 0,
currentQuestionId: first.id,
questionStartAt: serverTimestamp(),
questionDuration: 15
}, { merge: true })
} finally {
busy.value = false
}
}
async function reveal() {
if (!isHost.value) return
busy.value = true
try {
await updateDoc(doc(db, 'games', gid.value, 'state', 'current'), {
phase: 'reveal'
})
// Cloud Function will tally scores
} finally {
busy.value = false
}
}
async function next() {
if (!isHost.value) return
busy.value = true
try {
// Load all questions sorted by id, find next by index
const s = state.value
const qRef = collection(db, 'games', gid.value, 'questions')
const qSnap = await getDocs(query(qRef, orderBy('id')))
const docs = qSnap.docs
const nextIndex = (s?.questionIndex ?? -1) + 1
if (nextIndex >= docs.length) {
await endGame()
return
}
const nextQ = docs[nextIndex]
await setDoc(doc(db, 'games', gid.value, 'state', 'current'), {
phase: 'question',
questionIndex: nextIndex,
currentQuestionId: nextQ.id,
questionStartAt: serverTimestamp(),
questionDuration: 15
}, { merge: true })
} finally {
busy.value = false
}
}
async function endGame() {
if (!isHost.value) return
busy.value = true
try {
await updateDoc(doc(db, 'games', gid.value, 'state', 'current'), {
phase: 'ended'
})
} finally {
busy.value = false
}
}
</script>
<style scoped>
.wrap { max-width: 720px; margin: 2rem auto; padding: 1rem; }
.controls button { margin-right: 0.5rem; padding: 0.5rem 1rem; }
.state { margin-top: 1rem; }
</style>
11) Game page: question, answer, timer, and live leaderboard
components/Leaderboard.vue:
<template>
<div class="leaderboard">
<h3>Leaderboard</h3>
<ol>
<li v-for="p in players" :key="p.id">
<strong>{{ p.name }}</strong> – {{ p.score }}
</li>
</ol>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useFirebase } from '~/plugins/firebase.client'
import { collection, onSnapshot, orderBy, query, limit } from 'firebase/firestore'
const props = defineProps<{ gameId: string }>()
const { db } = useFirebase()
const players = ref<{ id: string; name: string; score: number }[]>([])
let unsub: (() => void) | null = null
onMounted(() => {
const ref = collection(db, 'games', props.gameId, 'players')
const q = query(ref, orderBy('score', 'desc'), limit(10))
unsub = onSnapshot(q, (snap) => {
players.value = snap.docs.map(d => ({ id: d.id, ...(d.data() as any) }))
})
})
onUnmounted(() => { if (unsub) unsub() })
</script>
<style scoped>
.leaderboard { border: 1px solid #e5e7eb; padding: 1rem; border-radius: 8px; }
ol { padding-left: 1.25rem; }
</style>
pages/game/[id].vue:
<template>
<div class="grid">
<div class="main">
<h1>Game: {{ gid }}</h1>
<p>Phase: <strong>{{ state?.phase }}</strong></p>
<div v-if="question">
<h2>{{ question.text }}</h2>
<div class="meta">
<span v-if="state?.phase === 'question'">Time left: {{ timeLeft }}</span>
<span v-else-if="state?.phase === 'reveal'">Answer: {{ question.correct }}</span>
<span v-else-if="state?.phase === 'ended'">Game Over</span>
</div>
<div class="options">
<button
v-for="o in question.options"
:key="o"
:disabled="answered || state?.phase !== 'question'"
:class="{ picked: picked === o }"
@click="submitAnswer(o)"
>
{{ o }}
</button>
</div>
<p v-if="answered">You picked: {{ picked }}</p>
</div>
<div v-else>
<p v-if="state?.phase === 'lobby'">Waiting for host to start…</p>
<p v-else>Loading question…</p>
</div>
</div>
<aside>
<Leaderboard :gameId="gid" />
</aside>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useFirebase } from '~/plugins/firebase.client'
import { doc, onSnapshot, collection, onSnapshot as onColSnapshot, setDoc, serverTimestamp, getDoc } from 'firebase/firestore'
import Leaderboard from '~/components/Leaderboard.vue'
const { db, auth } = useFirebase()
const route = useRoute()
const gid = computed(() => String(route.params.id).toUpperCase())
const state = ref<any | null>(null)
const question = ref<any | null>(null)
const picked = ref<string | null>(null)
const answered = ref(false)
let unsubState: (() => void) | null = null
let unsubQuestion: (() => void) | null = null
let timer: number | null = null
const timeLeft = ref('')
function updateTimeLeft() {
if (!state.value?.questionStartAt || !state.value?.questionDuration) {
timeLeft.value = ''
return
}
const start = state.value.questionStartAt.toDate ? state.value.questionStartAt.toDate() : new Date()
const duration = Number(state.value.questionDuration) * 1000
const end = start.getTime() + duration
const ms = Math.max(0, end - Date.now())
timeLeft.value = `${Math.ceil(ms / 1000)}s`
}
onMounted(() => {
const stateRef = doc(db, 'games', gid.value, 'state', 'current')
unsubState = onSnapshot(stateRef, async (s) => {
state.value = s.data()
// Load current question
if (state.value?.currentQuestionId) {
const qRef = doc(db, 'games', gid.value, 'questions', state.value.currentQuestionId)
if (unsubQuestion) unsubQuestion()
unsubQuestion = onSnapshot(qRef, (qs) => {
question.value = qs.data()
})
} else {
question.value = null
}
if (state.value?.phase === 'question') {
answered.value = false
picked.value = null
if (timer) window.clearInterval(timer)
timer = window.setInterval(updateTimeLeft, 200)
updateTimeLeft()
} else {
if (timer) window.clearInterval(timer)
timeLeft.value = ''
}
})
})
onUnmounted(() => {
if (unsubState) unsubState()
if (unsubQuestion) unsubQuestion()
if (timer) window.clearInterval(timer)
})
async function submitAnswer(option: string) {
if (answered.value) return
const uid = auth.currentUser?.uid
if (!uid) {
alert('Not signed in yet, try again.')
return
}
const qid = state.value?.currentQuestionId
if (!qid) return
picked.value = option
const ansRef = doc(db, 'games', gid.value, 'questions', qid, 'answers', uid)
try {
await setDoc(ansRef, {
answer: option,
at: serverTimestamp(),
name: (await getDoc(doc(db, 'games', gid.value, 'players', uid))).data()?.name || 'Player'
})
answered.value = true
} catch (e) {
console.error(e)
alert('Could not submit answer (maybe already answered this question).')
}
}
</script>
<style scoped>
.grid { display: grid; grid-template-columns: 1fr 300px; gap: 1rem; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; }
.meta { margin: 0.5rem 0; }
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 0.75rem; }
button.picked { background: #e5f2ff; }
aside { position: sticky; top: 1rem; height: fit-content; }
</style>
12) Try it locally
- Start Nuxt:
- npm run dev
- Open two browser windows:
- Window A (Host): go to /, click “Create Game” (copy ID), then open /host/ID
- Window B (Player): join with the same ID and a nickname
- Host flow:
- Start -> Question appears for all players
- Reveal -> Cloud Function increments scores for correct answers
- Next -> Moves to next question
- End -> Finalizes the game
- Watch the leaderboard update in real time.
13) Optional hardening and polish
- App Check: keep reCAPTCHA Enterprise site key in production. Use debug token locally if needed.
- Auth providers: switch from Anonymous to Google or email if you want persistent identities across sessions.
- Prevent answering after time: the rules block changing answers, but not timing. You can enforce timing on the server by ignoring late answers in the tally function (compare answer.at to state.questionStartAt + duration).
- Deploy hosting: Firebase Hosting, Vercel, or Netlify work well with Nuxt 4. If using Firebase Hosting + Functions, set up rewrites to SSR if desired.
Conclusion
You’ve built a modern, real-time Trivia Quiz with Nuxt 4 and Firebase:
- Nuxt 4 + TypeScript for a fast, DX-friendly UI
- Firestore real-time snapshots for questions and leaderboards
- Anonymous Auth + App Check (reCAPTCHA Enterprise) for low-friction, protected access
- Cloud Functions v2 to securely tally scores on reveal
- Firestore’s aggregate count() to show live player counts
Have fun extending it with richer host dashboards, animations, and custom categories!





