Collaborative Whiteboard with Presence using Nuxt 4 + TypeScript + Firebase

Build a real-time collaborative whiteboard with multi-user presence, live cursors, and offline persistence using Nuxt 4, TypeScript, Firebase Firestore, and Realtime Database.
Tags: Nuxt 4, Firebase, Firestore, Realtime Database, Presence
Time to read: 12 min
Why this is cool (and new)
- Nuxt 4 gives us a clean DX and stable app routing and auto-imports.
- Firestore’s persistentLocalCache with persistentMultipleTabManager lets us cache whiteboard data offline across multiple tabs seamlessly.
- Presence and live cursors are powered by Realtime Database using onDisconnect and .info/connected for robust online/offline awareness.
Prerequisites
- Familiarity with the command line
- Node.js 18+
- A Firebase project with Firestore and Realtime Database enabled
- Enable Anonymous Auth in Firebase Authentication
1) Create a fresh Nuxt 4 app
npx nuxi init nuxt4-whiteboard
cd nuxt4-whiteboard
npm install
npm run dev
Open http://localhost:3000 to verify.
2) Install Firebase SDK (modular v10+)
npm i firebase
3) Configure environment variables
Create a .env file in your project root and paste your Firebase web config (include databaseURL for Realtime Database):
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_STORAGE_BUCKET=YOUR_PROJECT.appspot.com
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=YOUR_SENDER_ID
NUXT_PUBLIC_FIREBASE_APP_ID=YOUR_APP_ID
NUXT_PUBLIC_FIREBASE_DATABASE_URL=https://YOUR_PROJECT_ID-default-rtdb.firebaseio.com
You can find these in Firebase Console > Project Settings > General > Your Apps.
4) Nuxt runtime config (nuxt.config.ts)
Expose your Firebase config via public runtimeConfig so it’s available client-side:
// nuxt.config.ts
export default defineNuxtConfig({
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,
storageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
databaseURL: process.env.NUXT_PUBLIC_FIREBASE_DATABASE_URL,
},
},
},
});
5) Firebase plugin with presence (plugins/firebase.client.ts)
- Initializes Firebase app once
- Sets Firestore with the latest multi-tab persistent cache
- Signs in anonymously
- Sets up robust presence using Realtime Database onDisconnect + .info/connected
// plugins/firebase.client.ts
import { getApp, getApps, initializeApp, type FirebaseApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, type Auth } from 'firebase/auth';
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
type Firestore,
} from 'firebase/firestore';
import {
getDatabase,
ref as rtdbRef,
onValue,
onDisconnect,
set,
serverTimestamp as rtdbServerTimestamp,
type Database,
} from 'firebase/database';
export default defineNuxtPlugin(async () => {
const config = useRuntimeConfig();
const firebaseConfig = config.public.firebase as {
apiKey: string;
authDomain: string;
projectId: string;
storageBucket: string;
messagingSenderId: string;
appId: string;
databaseURL: string;
};
const app: FirebaseApp = getApps().length ? getApp() : initializeApp(firebaseConfig);
const db: Firestore = initializeFirestore(app, {
// Newer Firestore persistent cache with multi-tab support
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
}),
});
const auth: Auth = getAuth(app);
// Anonymous sign-in for frictionless collab
await signInAnonymously(auth).catch(() => {
// No-op if user is already signed in or a transient error occurred
});
const rtdb: Database = getDatabase(app);
// Presence using Realtime Database .info/connected and onDisconnect
onAuthStateChanged(auth, (user) => {
if (!user) return;
const uid = user.uid;
const statusRef = rtdbRef(rtdb, `status/${uid}`);
const infoRef = rtdbRef(rtdb, '.info/connected');
onValue(infoRef, (snap) => {
if (snap.val() === false) return;
// Ensure offline state is set when the client disconnects
onDisconnect(statusRef)
.set({
state: 'offline',
last_changed: rtdbServerTimestamp(),
})
.catch(() => {});
// Immediately set as online
set(statusRef, {
state: 'online',
last_changed: rtdbServerTimestamp(),
}).catch(() => {});
});
});
return {
provide: {
firebase: { app, auth, db, rtdb },
},
};
});
Type augmentation (types/nuxt.d.ts)
// types/nuxt.d.ts
import type { FirebaseApp } from 'firebase/app';
import type { Auth } from 'firebase/auth';
import type { Firestore } from 'firebase/firestore';
import type { Database } from 'firebase/database';
declare module '#app' {
interface NuxtApp {
$firebase: {
app: FirebaseApp;
auth: Auth;
db: Firestore;
rtdb: Database;
};
}
}
export {};
6) Whiteboard composable (composables/useWhiteboard.ts)
- Streams strokes from Firestore
- Adds strokes in a single write per drag
- Tracks remote cursors via Realtime Database
- Updates your cursor throttled
// composables/useWhiteboard.ts
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useNuxtApp } from '#app';
import {
addDoc,
collection,
onSnapshot,
orderBy,
query,
serverTimestamp as fsServerTimestamp,
type DocumentData,
} from 'firebase/firestore';
import {
onValue,
ref as rtdbRef,
serverTimestamp as rtdbServerTimestamp,
set,
onDisconnect,
remove,
} from 'firebase/database';
export type Point = { x: number; y: number };
export type StrokeDoc = {
id?: string;
color: string;
width: number;
points: Point[];
uid: string;
createdAt: unknown;
};
export type CursorPayload = {
x: number;
y: number;
color: string;
name: string;
updatedAt?: unknown;
};
function randomName() {
const animals = ['Lynx', 'Otter', 'Panda', 'Fox', 'Hawk', 'Koala', 'Gecko', 'Yak', 'Moose', 'Tiger'];
const colors = ['Crimson', 'Azure', 'Mint', 'Amber', 'Violet', 'Teal', 'Coral', 'Lime', 'Indigo', 'Saffron'];
return `${colors[Math.floor(Math.random() * colors.length)]} ${animals[Math.floor(Math.random() * animals.length)]}`;
}
export function useWhiteboard(boardId = 'default') {
const { $firebase } = useNuxtApp();
const strokes = ref<StrokeDoc[]>([]);
const remoteCursors = ref<Record<string, CursorPayload>>({});
const myName = ref<string>(localStorage.getItem('wb_name') || randomName());
const myColor = ref<string>(localStorage.getItem('wb_color') || '#4A90E2');
// Persist identity
onMounted(() => {
localStorage.setItem('wb_name', myName.value);
localStorage.setItem('wb_color', myColor.value);
});
// Firestore strokes subscription
const strokesCol = collection($firebase.db, 'boards', boardId, 'strokes');
const q = query(strokesCol, orderBy('createdAt', 'asc'));
const unsubStrokes = onSnapshot(q, (snap) => {
strokes.value = snap.docs.map((d) => ({ id: d.id, ...(d.data() as DocumentData) })) as StrokeDoc[];
});
// Drawing state
let currentPoints: Point[] = [];
let currentMeta: { color: string; width: number; uid: string } | null = null;
function startStroke(p: Point, color: string, width: number) {
const uid = $firebase.auth.currentUser?.uid || 'anon';
currentPoints = [p];
currentMeta = { color, width, uid };
}
function addPoint(p: Point) {
if (!currentMeta) return;
currentPoints.push(p);
}
async function endStroke() {
if (!currentMeta) return;
try {
if (currentPoints.length > 1) {
await addDoc(strokesCol, {
color: currentMeta.color,
width: currentMeta.width,
points: currentPoints,
uid: currentMeta.uid,
createdAt: fsServerTimestamp(),
});
}
} finally {
currentPoints = [];
currentMeta = null;
}
}
// Cursors (Realtime DB)
const uid = $firebase.auth.currentUser?.uid || undefined;
// Listen for all cursors on this board
const cursorsRef = rtdbRef($firebase.rtdb, `cursors/${boardId}`);
const unsubCursors = onValue(cursorsRef, (snap) => {
const val = (snap.val() || {}) as Record<string, CursorPayload>;
const myUid = $firebase.auth.currentUser?.uid;
// Filter out self to avoid double-rendering if desired
const filtered: Record<string, CursorPayload> = {};
for (const k of Object.keys(val)) {
if (k !== myUid) filtered[k] = val[k];
}
remoteCursors.value = filtered;
});
// Update my cursor position, throttled
let lastSent = 0;
function updateMyCursor(x: number, y: number) {
const now = performance.now();
if (now - lastSent < 30) return; // ~33fps max
lastSent = now;
const me = $firebase.auth.currentUser;
if (!me) return;
const myRef = rtdbRef($firebase.rtdb, `cursors/${boardId}/${me.uid}`);
set(myRef, {
x,
y,
color: myColor.value,
name: myName.value,
updatedAt: rtdbServerTimestamp(),
}).catch(() => {});
}
// Cleanup cursor on disconnect and on unmount
onMounted(() => {
const me = $firebase.auth.currentUser;
if (!me) return;
const myRef = rtdbRef($firebase.rtdb, `cursors/${boardId}/${me.uid}`);
onDisconnect(myRef).remove().catch(() => {});
const onBeforeUnload = () => remove(myRef).catch(() => {});
window.addEventListener('beforeunload', onBeforeUnload);
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload);
remove(myRef).catch(() => {});
});
});
onBeforeUnmount(() => {
unsubStrokes();
unsubCursors();
});
return {
// data
strokes,
remoteCursors,
myName,
myColor,
// draw API
startStroke,
addPoint,
endStroke,
// presence API
updateMyCursor,
};
}
7) Whiteboard Canvas component (components/WhiteboardCanvas.vue)
- Responsive canvas with devicePixelRatio scaling
- Draws historical strokes + in-progress stroke
- Emits presence updates and shows remote cursors
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useWhiteboard, type Point, type StrokeDoc } from '~/composables/useWhiteboard';
type Props = {
boardId?: string;
};
const props = withDefaults(defineProps<Props>(), {
boardId: 'default',
});
const container = ref<HTMLDivElement | null>(null);
const canvas = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
const drawing = ref(false);
const brushColor = ref('#4A90E2');
const brushSize = ref(4);
const {
strokes,
remoteCursors,
myName,
myColor,
startStroke,
addPoint,
endStroke,
updateMyCursor,
} = useWhiteboard(props.boardId);
watch(brushColor, (v) => (myColor.value = v));
function sizeCanvas() {
if (!canvas.value || !container.value) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.value.getBoundingClientRect();
canvas.value.width = Math.floor(rect.width * dpr);
canvas.value.height = Math.floor(rect.height * dpr);
canvas.value.style.width = `${rect.width}px`;
canvas.value.style.height = `${rect.height}px`;
ctx = canvas.value.getContext('2d');
if (ctx) ctx.scale(dpr, dpr);
redraw();
}
function drawStroke(stroke: StrokeDoc) {
if (!ctx || stroke.points.length < 2) return;
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
const [first, ...rest] = stroke.points;
ctx.moveTo(first.x, first.y);
for (const p of rest) ctx.lineTo(p.x, p.y);
ctx.stroke();
}
let inProgress: { color: string; width: number; points: Point[] } | null = null;
function redraw() {
if (!ctx || !canvas.value) return;
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
// Redraw historical strokes
for (const s of strokes.value) drawStroke(s);
// Draw in-progress
if (inProgress && inProgress.points.length > 1) {
drawStroke({
color: inProgress.color,
width: inProgress.width,
points: inProgress.points,
uid: 'local',
createdAt: null as unknown as never,
});
}
}
function toCanvasPoint(e: PointerEvent): Point {
const rect = canvas.value!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onPointerDown(e: PointerEvent) {
if (!canvas.value) return;
canvas.value.setPointerCapture(e.pointerId);
drawing.value = true;
const p = toCanvasPoint(e);
startStroke(p, brushColor.value, brushSize.value);
inProgress = { color: brushColor.value, width: brushSize.value, points: [p] };
redraw();
}
function onPointerMove(e: PointerEvent) {
if (!canvas.value) return;
const p = toCanvasPoint(e);
updateMyCursor(p.x, p.y);
if (!drawing.value) return;
addPoint(p);
inProgress?.points.push(p);
redraw();
}
async function onPointerUp(e: PointerEvent) {
if (!canvas.value) return;
drawing.value = false;
await endStroke();
inProgress = null;
redraw();
}
onMounted(() => {
sizeCanvas();
window.addEventListener('resize', sizeCanvas);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', sizeCanvas);
});
watch(strokes, redraw);
</script>
<template>
<div ref="container" class="wb-container">
<div class="toolbar">
<label>
Name:
<input v-model="myName" placeholder="Your name" />
</label>
<label>
Color:
<input v-model="brushColor" type="color" />
</label>
<label>
Size:
<input v-model.number="brushSize" type="range" min="1" max="20" />
<span>{{ brushSize }}</span>
</label>
</div>
<div class="board">
<canvas
ref="canvas"
class="canvas"
@pointerdown.prevent="onPointerDown"
@pointermove.prevent="onPointerMove"
@pointerup.prevent="onPointerUp"
@pointercancel.prevent="onPointerUp"
></canvas>
<!-- Remote cursors -->
<template v-for="(c, id) in remoteCursors" :key="id">
<div
class="cursor"
:style="{ transform: `translate(${c.x}px, ${c.y}px)`, borderColor: c.color }"
:title="`${c.name}`"
>
<div class="dot" :style="{ backgroundColor: c.color }"></div>
<div class="label">{{ c.name }}</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.wb-container {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 80vh;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.toolbar input[type='color'] {
width: 36px;
height: 24px;
padding: 0;
border: 0;
background: none;
}
.board {
position: relative;
flex: 1;
min-height: 480px;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.canvas {
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
display: block;
}
.cursor {
position: absolute;
pointer-events: none;
transform: translate(-2px, -24px);
display: flex;
align-items: center;
gap: 6px;
border-left: 2px solid;
}
.cursor .dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.cursor .label {
font-size: 12px;
background: rgba(255, 255, 255, 0.9);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #e5e7eb;
color: #111827;
}
</style>
8) Page to mount the whiteboard (pages/index.vue)
<script setup lang="ts">
</script>
<template>
<section>
<h1>Nuxt 4 Collaborative Whiteboard</h1>
<p>Draw together in real-time. Presence and cursors powered by Firebase.</p>
<WhiteboardCanvas board-id="default" />
</section>
</template>
9) Firebase Security Rules
Firestore rules (allow basic, safe strokes writing)
// Firestore rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isAuthed() {
return request.auth != null;
}
match /boards/{boardId}/strokes/{strokeId} {
allow read: if true; // Public read for demo
allow create: if isAuthed()
&& request.resource.data.keys().hasAll(['color','width','points','uid','createdAt'])
&& request.resource.data.uid == request.auth.uid
&& request.resource.data.width >= 1
&& request.resource.data.width <= 24
&& request.resource.data.points is list
&& request.resource.data.points.size() <= 500
&& request.resource.data.color.matches('^#([0-9a-fA-F]{6})$');
// Prevent edits/deletes in demo
allow update, delete: if false;
}
}
}
Realtime Database rules (presence and cursors)
{
"rules": {
".read": true, // demo
"status": {
"$uid": {
".write": "auth != null && auth.uid === $uid"
}
},
"cursors": {
"$board": {
"$uid": {
".write": "auth != null && auth.uid === $uid"
}
}
}
}
}
Note: For production, tighten reads and consider rate-limiting strategies.
10) Enable products in Firebase Console
- Authentication > Sign-in method > Anonymous: Enabled
- Firestore Database > Start in test mode (or set secure rules)
- Realtime Database > Create Database > Start in test mode (or set secure rules)
11) Run it
npm run dev
- Open multiple tabs or devices at http://localhost:3000
- Watch live cursors and presence
- Draw strokes; they sync through Firestore with offline cache and multi-tab coordination
Optional enhancements
- Eraser tool: Add stroke type and render accordingly
- Undo/Redo: Store stroke IDs locally, implement command stack
- PWA: Add @vite-pwa/nuxt to make the whiteboard installable and robust offline
- Rate limiting: Batch cursor updates further or debounce based on network conditions
Conclusion
You now have a Nuxt 4 + TypeScript collaborative whiteboard with:
- Real-time drawing via Firestore
- Robust presence and live cursors via Realtime Database
- Modern Firestore persistentLocalCache with multi-tab support for an offline-friendly experience
Build on this foundation to add tools, boards, and export features.