November 17, 202512 min

Collaborative Whiteboard with Presence using Nuxt 4 + TypeScript + Firebase

Build a real-time collaborative whiteboard with multi-user presence, cursors, and offline cache using Nuxt 4, TypeScript, Firestore (persistentLocalCache), and Realtime Database.

Collaborative Whiteboard with Presence using Nuxt 4 + TypeScript + Firebase

nuxt-firebase-whiteboard

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.