November 27, 20259 min

Community Heatmap of Favorite Spots in Nuxt 4 + TypeScript + Mapbox

Build a real-time community heatmap of favorite spots using Nuxt 4, TypeScript, Mapbox GL JS v3, and Firebase.

Community Heatmap of Favorite Spots in Nuxt 4 + TypeScript + Mapbox

nuxt-mapbox-heatmap

Build a live-updating community heatmap with Nuxt 4, Mapbox GL JS v3 (globe projection + fog), and Firebase for realtime storage.

Tags: Nuxt 4, Mapbox GL JS v3, Firebase, TypeScript

Time to read: 9 min

What we’ll build

  • A Nuxt 4 app that renders a Mapbox GL JS v3 map with globe projection and atmospheric fog
  • A heatmap layer fed by community-submitted points (favorite spots)
  • Realtime updates via Firebase Firestore
  • Anonymous auth for simple submissions

This tutorial uses Mapbox GL JS v3 (latest major with modern ESM build, globe projection, and fog) and Nuxt 4.

Prerequisites

  • Node.js 18+
  • A Mapbox account and access token
  • A Firebase project with Firestore enabled
  • Basic familiarity with Nuxt and TypeScript

1) Create the Nuxt 4 app

npx nuxi init nuxt4-heatmap
cd nuxt4-heatmap
npm install

Run it:

npm run dev

Visit http://localhost:3000 to confirm it works.

2) Install dependencies

npm i mapbox-gl firebase

Notes:

  • Mapbox GL JS v3 ships TypeScript types out of the box.
  • Firebase v11+ modular SDK is used in this guide.

3) Environment variables

Create a .env file at the project root:

# Mapbox
NUXT_PUBLIC_MAPBOX_TOKEN=pk.your_mapbox_token_here

# Firebase web app config (from 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

In Firebase Console:

  • Enable Firestore (in Native mode)
  • Enable “Anonymous” sign-in method in Authentication

4) Nuxt config

Add Mapbox CSS, runtimeConfig, and strict TS. Update nuxt.config.ts:

export default defineNuxtConfig({
  devtools: { enabled: true },
  css: ['mapbox-gl/dist/mapbox-gl.css'],
  typescript: {
    strict: true
  },
  runtimeConfig: {
    public: {
      mapboxToken: process.env.NUXT_PUBLIC_MAPBOX_TOKEN,
      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
      }
    }
  }
});

5) Firebase plugin (client)

Create plugins/firebase.client.ts:

import { defineNuxtPlugin, useRuntimeConfig } from '#app';
import { getApps, getApp, initializeApp } from 'firebase/app';
import { getAuth, browserLocalPersistence, setPersistence } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig().public.firebase as {
    apiKey: string;
    authDomain: string;
    projectId: string;
    appId: string;
  };

  const app = getApps().length ? getApp() : initializeApp(config);
  const auth = getAuth(app);
  // Best-effort: ensure auth persists in browser
  setPersistence(auth, browserLocalPersistence).catch(() => {});

  const db = getFirestore(app);

  return {
    provide: {
      firebase: { app, auth, db }
    }
  };
});

6) Firestore composable for spots

Create composables/useSpots.ts:

import { ref } from 'vue';
import { useNuxtApp } from '#app';
import {
  addDoc,
  collection,
  onSnapshot,
  serverTimestamp,
  Unsubscribe
} from 'firebase/firestore';
import { signInAnonymously } from 'firebase/auth';

type Category = 'food' | 'park' | 'view' | 'other';

type SpotDoc = {
  uid: string;
  label: string;
  category: Category;
  heat: number; // 1..5
  location: { lat: number; lng: number };
  createdAt: unknown; // timestamp in Firestore; typed loosely for rules compatibility
};

type SpotFeature = {
  type: 'Feature';
  geometry: { type: 'Point'; coordinates: [number, number] };
  properties: { label: string; category: Category; heat: number };
};

type SpotFeatureCollection = {
  type: 'FeatureCollection';
  features: SpotFeature[];
};

export const useSpots = () => {
  const { $firebase } = useNuxtApp();
  const fc = ref<SpotFeatureCollection>({ type: 'FeatureCollection', features: [] });
  let unsub: Unsubscribe | null = null;

  const start = () => {
    const colRef = collection($firebase.db, 'spots');
    unsub = onSnapshot(colRef, (snap) => {
      const features: SpotFeature[] = snap.docs.map((d) => {
        const s = d.data() as SpotDoc;
        return {
          type: 'Feature',
          geometry: { type: 'Point', coordinates: [s.location.lng, s.location.lat] },
          properties: {
            label: s.label,
            category: s.category,
            heat: Math.max(1, Math.min(5, Number(s.heat || 1)))
          }
        };
      });
      fc.value = { type: 'FeatureCollection', features };
    });
  };

  const stop = () => {
    if (unsub) {
      unsub();
      unsub = null;
    }
  };

  const addSpot = async (input: {
    lng: number;
    lat: number;
    label: string;
    category: Category;
    heat: number; // 1..5
  }) => {
    const label = input.label.trim().slice(0, 80);
    const heat = Math.max(1, Math.min(5, Math.round(Number(input.heat))));
    const category = input.category;

    // Ensure anonymous auth
    if (!$firebase.auth.currentUser) {
      await signInAnonymously($firebase.auth);
    }

    await addDoc(collection($firebase.db, 'spots'), {
      uid: $firebase.auth.currentUser?.uid ?? 'anon',
      label,
      category,
      heat,
      location: { lat: input.lat, lng: input.lng },
      createdAt: serverTimestamp()
    } satisfies SpotDoc);
  };

  return {
    collection: fc,
    start,
    stop,
    addSpot
  };
};

7) The Map + Heatmap component

Create components/MapHeatmap.vue:

<template>
  <div class="map-wrap">
    <div ref="container" class="map" />
    <div class="panel">
      <h3>Add your favorite spot</h3>
      <p class="muted">Click on the map to pick a location.</p>

      <div class="row">
        <label>Selected</label>
        <div v-if="selectedLngLat" class="coords">
          {{ selectedLngLat[1].toFixed(5) }}, {{ selectedLngLat[0].toFixed(5) }}
        </div>
        <div v-else class="coords empty">None</div>
      </div>

      <div class="row">
        <label for="label">Label</label>
        <input id="label" v-model="label" maxlength="80" placeholder="e.g. Best tacos" />
      </div>

      <div class="row">
        <label for="category">Category</label>
        <select id="category" v-model="category">
          <option value="food">Food</option>
          <option value="park">Park</option>
          <option value="view">View</option>
          <option value="other">Other</option>
        </select>
      </div>

      <div class="row">
        <label for="heat">Heat (1–5)</label>
        <input id="heat" type="number" min="1" max="5" v-model.number="heat" />
      </div>

      <button :disabled="!canSubmit" @click="submitSpot">Submit Spot</button>

      <p v-if="!hasToken" class="warn">
        Missing Mapbox token. Set NUXT_PUBLIC_MAPBOX_TOKEN in .env
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, shallowRef, watch, computed } from 'vue';
import mapboxgl from 'mapbox-gl';
import { useSpots } from '@/composables/useSpots';

const runtime = useRuntimeConfig();
const mapboxToken = runtime.public.mapboxToken as string;

const container = ref<HTMLDivElement | null>(null);
const map = shallowRef<mapboxgl.Map | null>(null);

const spots = useSpots();
const selectedLngLat = ref<[number, number] | null>(null);
const label = ref('');
const category = ref<'food' | 'park' | 'view' | 'other'>('other');
const heat = ref(3);

const hasToken = computed(() => Boolean(mapboxToken));
const canSubmit = computed(() => !!selectedLngLat.value && label.value.trim().length > 0);

onMounted(() => {
  spots.start();

  if (!container.value || !hasToken.value) return;

  const m = new mapboxgl.Map({
    accessToken: mapboxToken,
    container: container.value,
    style: 'mapbox://styles/mapbox/light-v11',
    center: [0, 20],
    zoom: 1.5,
    projection: 'globe'
  });
  map.value = m;

  m.addControl(new mapboxgl.NavigationControl());
  m.addControl(new mapboxgl.ScaleControl({ unit: 'metric' }));

  m.on('style.load', () => {
    // Atmosphere/fog for Mapbox GL JS v3
    m.setFog({
      color: 'rgb(186, 210, 235)',
      'high-color': 'rgb(36, 92, 223)',
      'space-color': 'rgb(11, 11, 25)',
      'horizon-blend': 0.02
    });

    m.addSource('spots', {
      type: 'geojson',
      data: spots.collection.value
    });

    m.addLayer({
      id: 'spots-heat',
      type: 'heatmap',
      source: 'spots',
      maxzoom: 15,
      paint: {
        'heatmap-weight': [
          'interpolate',
          ['linear'],
          ['get', 'heat'],
          0, 0,
          5, 1
        ],
        'heatmap-intensity': [
          'interpolate',
          ['linear'],
          ['zoom'],
          0, 0.5,
          15, 3
        ],
        'heatmap-color': [
          'interpolate',
          ['linear'],
          ['heatmap-density'],
          0, 'rgba(0,0,255,0)',
          0.2, 'rgb(65, 105, 225)',
          0.4, 'rgb(0, 255, 255)',
          0.6, 'rgb(0, 255, 127)',
          0.8, 'rgb(255, 255, 0)',
          1, 'rgb(255, 69, 0)'
        ],
        'heatmap-radius': [
          'interpolate',
          ['linear'],
          ['zoom'],
          0, 2,
          9, 20,
          15, 40
        ],
        'heatmap-opacity': [
          'interpolate',
          ['linear'],
          ['zoom'],
          7, 0.8,
          15, 0.4
        ]
      }
    });

    m.addLayer({
      id: 'spots-point',
      type: 'circle',
      source: 'spots',
      minzoom: 10,
      paint: {
        'circle-radius': [
          'interpolate',
          ['linear'],
          ['zoom'],
          10, 2,
          16, 8
        ],
        'circle-color': [
          'match',
          ['get', 'category'],
          'food', '#ff6b6b',
          'park', '#4caf50',
          'view', '#2196f3',
          /* other */ '#9c27b0'
        ],
        'circle-stroke-color': 'white',
        'circle-stroke-width': 1,
        'circle-opacity': 0.85
      }
    });
  });

  m.on('click', (e) => {
    selectedLngLat.value = [e.lngLat.lng, e.lngLat.lat];
  });
});

watch(
  () => spots.collection.value,
  (fc) => {
    const m = map.value;
    if (!m) return;
    const source = m.getSource('spots') as mapboxgl.GeoJSONSource | undefined;
    if (source) {
      source.setData(fc as any);
    }
  },
  { deep: true }
);

onBeforeUnmount(() => {
  spots.stop();
  map.value?.remove();
});

async function submitSpot() {
  if (!selectedLngLat.value) return;
  const [lng, lat] = selectedLngLat.value;
  await spots.addSpot({
    lng,
    lat,
    label: label.value,
    category: category.value,
    heat: heat.value
  });
  label.value = '';
}
</script>

<style scoped>
.map-wrap {
  display: grid;
  grid-template-columns: 1fr 320px;
  gap: 16px;
  height: calc(100vh - 120px);
}

.map {
  width: 100%;
  height: 100%;
  border-radius: 8px;
  overflow: hidden;
}

.panel {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.row {
  display: grid;
  grid-template-columns: 88px 1fr;
  gap: 8px;
  align-items: center;
}

label {
  font-weight: 600;
  color: #374151;
}

input, select {
  border: 1px solid #d1d5db;
  border-radius: 6px;
  padding: 8px;
  outline: none;
}

button {
  padding: 10px 12px;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
button:disabled {
  background: #93c5fd;
  cursor: not-allowed;
}

.muted {
  color: #6b7280;
  margin-top: -6px;
}

.coords {
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  color: #374151;
}
.coords.empty {
  color: #9ca3af;
}

.warn {
  color: #b45309;
  background: #fff7ed;
  border: 1px solid #fed7aa;
  padding: 8px;
  border-radius: 6px;
}
@media (max-width: 900px) {
  .map-wrap {
    grid-template-columns: 1fr;
    grid-template-rows: 1fr auto;
    height: auto;
  }
  .map {
    height: 60vh;
  }
}
</style>

8) Page setup

Use the component on the homepage. Create pages/index.vue:

<template>
  <div class="page">
    <h1>Community Heatmap of Favorite Spots</h1>
    <p class="lead">
      Click anywhere to select a location, fill a few details, and submit. The heatmap updates in realtime.
    </p>
    <ClientOnly>
      <MapHeatmap />
    </ClientOnly>
  </div>
</template>

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

<style scoped>
.page {
  padding: 24px;
}
.lead {
  color: #6b7280;
  margin-bottom: 16px;
}
</style>

9) Firestore security rules

Lock down writes to safe fields and allow public reads. In Firebase Console -> Firestore -> Rules:

// Firestore Rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /spots/{id} {
      allow read: if true;

      allow create: if request.auth != null
        && request.resource.data.keys().hasOnly(['uid','label','category','heat','location','createdAt'])
        && request.resource.data.uid == request.auth.uid
        && request.resource.data.label is string
        && request.resource.data.label.size() <= 80
        && request.resource.data.category in ['food','park','view','other']
        && request.resource.data.heat is number
        && request.resource.data.heat >= 1 && request.resource.data.heat <= 5
        && request.resource.data.location is map
        && request.resource.data.location.lat is number
        && request.resource.data.location.lng is number
        && request.resource.data.createdAt is timestamp;

      allow update, delete: if false;
    }
  }
}

Publish the rules.

10) Run it

npm run dev
  • Ensure the .env has your Mapbox token and Firebase config
  • Click on the map to select a coordinate, fill out the form, and submit
  • Open the app in two browser windows — submissions in one will update the heatmap in the other in realtime

Highlights of new Mapbox GL JS v3 features used

  • Globe projection: set projection: 'globe' for a modern global experience
  • Fog/atmosphere via map.setFog for a polished, 3D-like look
  • Modern ESM build and improved performance over v2

Production tips

  • Restrict your Mapbox token to your domain in Mapbox dashboard
  • Consider deduplicating near-identical points server-side if your dataset grows
  • Use category-specific heat weights by adjusting heatmap-weight or pre-processing in Firestore Cloud Functions

Conclusion

You just built a realtime community heatmap powered by Nuxt 4, TypeScript, Mapbox GL JS v3, and Firebase. The globe projection and fog make the map feel premium, and Firestore keeps updates snappy. Extend this further with moderation, user profiles, or custom filters by category.