Maps Integration
The @friendlyinternet/nuxt-crouton-maps package provides seamless map integration for your Nuxt Crouton applications using Mapbox GL JS. Add interactive maps, geocoding, and location-based features to your app.
Package Information
{
"name": "@friendlyinternet/nuxt-crouton-maps",
"version": "0.3.0",
"status": "BETA"
}
Installation
Install the beta package:
pnpm add @friendlyinternet/nuxt-crouton-maps
nuxt-mapbox (v1.6.4+) and mapbox-gl (v3.0.0+) under the hood.Setup
1. Get a Mapbox Access Token
- Sign up for a free Mapbox account: https://account.mapbox.com/
- Get your access token: https://account.mapbox.com/access-tokens/
- Free tier includes:
- 50,000 map loads/month
- 100,000 geocoding requests/month
2. Configure Your Project
Add the layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@friendlyinternet/nuxt-crouton',
'@friendlyinternet/nuxt-crouton-maps' // Add maps layer
],
runtimeConfig: {
public: {
mapbox: {
accessToken: process.env.MAPBOX_TOKEN,
// Optional defaults
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749], // [lng, lat]
zoom: 12
}
}
}
})
3. Add Environment Variable
Create or update your .env file:
MAPBOX_TOKEN=pk.eyJ1IjoieW91ciIsImEiOiJ0b2tlbiJ9...
.env file to version control. Add it to your .gitignore.Components
CroutonMapsMap
Interactive map component with loading states and error handling.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | auto-generated | Map container ID |
center | [number, number] | From config | Initial center coordinates [lng, lat] |
zoom | number | 12 | Initial zoom level (0-22) |
style | string | streets-v12 | Mapbox style URL or preset |
height | string | '400px' | Container height (CSS value) |
width | string | '100%' | Container width (CSS value) |
class | string | - | Additional CSS classes |
flyToOnCenterChange | boolean | false | Animate center changes |
flyToDuration | number | 800 | Animation duration (ms) |
flyToEasing | function | easeInOutCubic | Easing function |
Events
| Event | Payload | Description |
|---|---|---|
@load | map: Map | Map instance loaded and ready |
@error | error: string | Map failed to load |
Slots
| Slot | Props | Description |
|---|---|---|
default | { map: Map } | Scoped slot for markers/popups |
Example
<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])
const zoom = ref(12)
const handleMapLoad = (map) => {
console.log('Map loaded:', map)
}
</script>
<template>
<CroutonMapsMap
:center="center"
:zoom="zoom"
height="500px"
@load="handleMapLoad"
>
<template #default="{ map }">
<!-- Add markers here -->
</template>
</CroutonMapsMap>
</template>
CroutonMapsMarker
Map marker with optional popup and smooth animations.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
map | Map | ✅ | Map instance from parent |
position | [number, number] | ✅ | Marker position [lng, lat] |
color | string | - | Marker color (hex/rgb/named) |
options | MarkerOptions | - | Mapbox marker options |
popupContent | string | - | HTML content for popup |
animateTransitions | boolean | true | Smooth position animation |
animationDuration | number | 800 | Animation duration (ms) |
animationEasing | string | function | 'easeInOutCubic' | Easing preset or function |
Events
| Event | Description |
|---|---|
@click | Marker clicked |
@dragStart | Drag started (requires draggable: true) |
@drag | Dragging in progress |
@dragEnd | Drag ended |
Example
<template>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsMarker
:map="map"
:position="[-122.4194, 37.7749]"
color="#ef4444"
popup-content="<h3>San Francisco</h3>"
@click="handleMarkerClick"
/>
</template>
</CroutonMapsMap>
</template>
Draggable Markers
<script setup lang="ts">
const markerPosition = ref<[number, number]>([-122.4194, 37.7749])
const handleDragEnd = () => {
console.log('New position:', markerPosition.value)
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="markerPosition"
:options="{ draggable: true }"
@dragEnd="handleDragEnd"
/>
</template>
CroutonMapsPopup
Standalone popup component for custom content.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
map | Map | - | Map instance (required) |
position | [number, number] | - | Popup position (required) |
options | PopupOptions | - | Mapbox popup options |
closeButton | boolean | true | Show close button |
closeOnClick | boolean | true | Close on map click |
maxWidth | string | '240px' | Maximum width |
Events
| Event | Description |
|---|---|
@open | Popup opened |
@close | Popup closed |
Slots
| Slot | Description |
|---|---|
default | Popup content (supports Vue components) |
Example
<template>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsPopup
:map="map"
:position="[-122.4194, 37.7749]"
max-width="300px"
>
<div class="p-4">
<h3 class="font-bold">Custom Popup</h3>
<p>Vue components work here!</p>
<UButton size="xs">Action</UButton>
</div>
</CroutonMapsPopup>
</template>
</CroutonMapsMap>
</template>
CroutonMapsPreview
Compact map preview with modal expansion - perfect for forms and cards.
Props
| Prop | Type | Description |
|---|---|---|
location | string | [number, number] | Location as coordinates or JSON string |
Example
<script setup lang="ts">
const location = ref<[number, number]>([-122.4194, 37.7749])
// Or from a collection field
// const location = ref(item.coordinates) // "[lon, lat]" string
</script>
<template>
<div class="space-y-4">
<!-- Compact preview with click-to-expand -->
<CroutonMapsPreview :location="location" />
</div>
</template>
Composables
useMap()
Core composable for managing map instances directly.
const {
map, // Computed<Map | null>
isLoaded, // Readonly<Ref<boolean>>
error, // Readonly<Ref<string | null>>
container, // Readonly<Ref<HTMLElement | null>>
initialize, // (options: UseMapOptions) => Promise<void>
destroy, // () => void
resize // () => void
} = useMap()
Example
<script setup lang="ts">
const { map, isLoaded, error, initialize } = useMap()
onMounted(async () => {
await initialize({
container: 'my-map',
center: [-122.4194, 37.7749],
zoom: 12
})
// Access map instance directly
if (map.value) {
map.value.on('click', (e) => {
console.log('Clicked at:', e.lngLat)
})
}
})
</script>
<template>
<div id="my-map" style="height: 500px;" />
</template>
useMapConfig()
Access runtime configuration for Mapbox.
const {
accessToken, // string
style, // string
center, // [number, number] | undefined
zoom // number
} = useMapConfig()
Example
<script setup lang="ts">
const config = useMapConfig()
console.log('Using style:', config.style)
console.log('Default center:', config.center)
</script>
useGeocode()
Forward and reverse geocoding with Mapbox API.
const {
geocode, // (query: string) => Promise<GeocodeResult | null>
reverseGeocode, // (coords: [number, number]) => Promise<GeocodeResult | null>
loading, // Readonly<Ref<boolean>>
error // Readonly<Ref<string | null>>
} = useGeocode()
Forward Geocoding (Address → Coordinates)
<script setup lang="ts">
const { geocode, loading, error } = useGeocode()
const address = ref('1600 Amphitheatre Parkway, Mountain View, CA')
const result = ref<GeocodeResult | null>(null)
const searchAddress = async () => {
result.value = await geocode(address.value)
if (result.value) {
console.log('Coordinates:', result.value.coordinates)
console.log('Full address:', result.value.address)
}
}
</script>
<template>
<div class="space-y-4">
<UInput
v-model="address"
placeholder="Enter an address"
/>
<UButton @click="searchAddress" :loading="loading">
Search
</UButton>
<div v-if="result" class="p-4 bg-gray-50 rounded">
<p class="font-semibold">{{ result.address }}</p>
<p class="text-sm text-gray-600">
{{ result.coordinates[0] }}, {{ result.coordinates[1] }}
</p>
</div>
<div v-if="error" class="text-red-500">
{{ error }}
</div>
</div>
</template>
Reverse Geocoding (Coordinates → Address)
<script setup lang="ts">
const { reverseGeocode } = useGeocode()
const coordinates = ref<[number, number]>([-122.4194, 37.7749])
const address = ref('')
const findAddress = async () => {
const result = await reverseGeocode(coordinates.value)
if (result) {
address.value = result.address
}
}
</script>
GeocodeResult Type
interface GeocodeResult {
coordinates: [number, number]
address: string
placeName: string
context?: {
postcode?: string
place?: string
region?: string
country?: string
}
}
useMapboxStyles()
Helper for working with Mapbox style presets.
const {
styles, // All available Mapbox style URLs
getStyle // (preset: string) => string
} = useMapboxStyles()
Available Styles
| Preset | URL | Description |
|---|---|---|
standard | mapbox://styles/mapbox/standard | New 3D style (recommended) |
streets | mapbox://styles/mapbox/streets-v12 | Classic street map |
outdoors | mapbox://styles/mapbox/outdoors-v12 | Outdoor/hiking map |
light | mapbox://styles/mapbox/light-v11 | Light theme |
dark | mapbox://styles/mapbox/dark-v11 | Dark theme |
satellite | mapbox://styles/mapbox/satellite-v9 | Satellite imagery |
satelliteStreets | mapbox://styles/mapbox/satellite-streets-v12 | Satellite + streets |
navigationDay | mapbox://styles/mapbox/navigation-day-v1 | Navigation (day) |
navigationNight | mapbox://styles/mapbox/navigation-night-v1 | Navigation (night) |
Example
<script setup lang="ts">
const { styles, getStyle } = useMapboxStyles()
const selectedStyle = ref('dark')
const mapStyle = computed(() => getStyle(selectedStyle.value))
</script>
<template>
<div>
<select v-model="selectedStyle">
<option value="streets">Streets</option>
<option value="dark">Dark</option>
<option value="satellite">Satellite</option>
</select>
<CroutonMapsMap
:style="mapStyle"
:center="[-122.4194, 37.7749]"
:zoom="12"
height="500px"
/>
</div>
</template>
useMarkerColor()
Automatically sync marker colors with your Nuxt UI theme.
const markerColor = useMarkerColor() // Ref<string> (hex color)
Example
<script setup lang="ts">
const markerColor = useMarkerColor()
// Automatically uses --ui-primary from your theme
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:color="markerColor"
/>
</template>
Complete Examples
Interactive Search with Map
Combine geocoding with animated map navigation:
Search with geocoding:
<script setup lang="ts">
const { geocode, loading } = useGeocode()
const searchQuery = ref('')
const center = ref<[number, number]>([-122.4194, 37.7749])
const handleSearch = async () => {
const result = await geocode(searchQuery.value)
if (result) {
center.value = result.coordinates
}
}
</script>
<template>
<div class="space-y-4">
<div class="flex gap-2">
<UInput
v-model="searchQuery"
placeholder="Search for a place..."
class="flex-1"
@keyup.enter="handleSearch"
/>
<UButton @click="handleSearch" :loading="loading">Search</UButton>
</div>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsMarker :map="map" :position="center" color="#ef4444" />
</template>
</CroutonMapsMap>
</div>
</template>
With smooth animations:
<script setup lang="ts">
const mapInstance = ref(null)
const handleSearch = async () => {
const result = await geocode(searchQuery.value)
if (result && mapInstance.value) {
mapInstance.value.flyTo({
center: result.coordinates,
zoom: 14,
duration: 2000
})
}
}
</script>
<template>
<CroutonMapsMap @load="(map) => mapInstance = map">
<!-- Map content -->
</CroutonMapsMap>
</template>
Store Locator
Display collection items on a map:
Store list:
<script setup lang="ts">
const { items: stores } = useCollection('stores')
const selectedStore = ref(null)
</script>
<template>
<div class="space-y-4">
<div
v-for="store in stores"
:key="store.id"
class="p-4 border rounded hover:bg-gray-50"
:class="{ 'bg-blue-50': selectedStore?.id === store.id }"
>
<h3 class="font-bold">{{ store.name }}</h3>
<p class="text-sm text-gray-600">{{ store.address }}</p>
</div>
</div>
</template>
Map with multiple markers:
<script setup lang="ts">
const { items: stores } = useCollection('stores')
const storeMarkers = computed(() =>
stores.value.map(store => ({
id: store.id,
position: [store.longitude, store.latitude],
name: store.name,
address: store.address
}))
)
const handleMarkerClick = (store) => {
selectedStore.value = store
}
</script>
<template>
<CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
<template #default="{ map }">
<CroutonMapsMarker
v-for="marker in storeMarkers"
:key="marker.id"
:map="map"
:position="marker.position"
:popup-content="`<div class='p-2'><h3 class='font-bold'>${marker.name}</h3></div>`"
@click="handleMarkerClick(marker)"
/>
</template>
</CroutonMapsMap>
</template>
Collection with Map Field
Add location tracking to your collections:
<script setup lang="ts">
const { items } = useCollection('events')
// Assume your collection has latitude/longitude fields
const eventMarkers = computed(() =>
items.value
.filter(event => event.latitude && event.longitude)
.map(event => ({
id: event.id,
position: [event.longitude, event.latitude],
title: event.title,
date: event.date
}))
)
</script>
<template>
<CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
<template #default="{ map }">
<CroutonMapsMarker
v-for="marker in eventMarkers"
:key="marker.id"
:map="map"
:position="marker.position"
>
<template #popup>
<div class="p-4">
<h3 class="font-bold">{{ marker.title }}</h3>
<p class="text-sm text-gray-600">{{ marker.date }}</p>
<UButton size="xs" class="mt-2">
View Details
</UButton>
</div>
</template>
</CroutonMapsMarker>
</template>
</CroutonMapsMap>
</template>
TypeScript Support
All types are exported from the package:
import type {
// Main types
MapConfig,
MapInstance,
MarkerInstance,
PopupInstance,
// Options
UseMapOptions,
UseMarkerOptions,
// Results
GeocodeResult,
// Animations
EasingFunction,
EasingPreset,
MarkerAnimationOptions,
MapFlyToOptions,
// Style presets
MapboxStylePreset,
// Re-exported Mapbox GL types
Map,
Marker,
Popup,
MapOptions,
MarkerOptions,
PopupOptions,
LngLatLike
} from '@friendlyinternet/nuxt-crouton-maps'
Animation System
Both maps and markers support smooth animations.
Map Animations (flyTo)
<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])
const goToNewYork = () => {
center.value = [-74.0060, 40.7128]
}
</script>
<template>
<CroutonMapsMap
:center="center"
:zoom="12"
height="500px"
:flyToOnCenterChange="true"
:flyToDuration="1500"
/>
</template>
Marker Animations
<script setup lang="ts">
const position = ref<[number, number]>([-122.4194, 37.7749])
// Position updates will animate smoothly
const moveMarker = () => {
position.value = [-122.4084, 37.7749]
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:animateTransitions="true"
:animationDuration="1000"
animationEasing="easeInOutCubic"
/>
</template>
Custom Easing Functions
<script setup lang="ts">
// Bounce easing
const bounceEasing = (t: number): number => {
return t < 0.5
? 8 * t * t * t * t
: 1 - 8 * (--t) * t * t * t
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:animationEasing="bounceEasing"
/>
</template>
Best Practices
Performance
<script setup lang="ts">
// Instead of rendering all markers
const visibleMarkers = computed(() => {
// Filter markers based on map bounds
return allMarkers.value.filter(marker => {
// Check if marker is in viewport
return isInBounds(marker.position)
})
})
</script>
Error Handling
Always handle geocoding errors gracefully:
<script setup lang="ts">
const { geocode, error } = useGeocode()
const search = async (query: string) => {
const result = await geocode(query)
if (error.value) {
// Show user-friendly error
toast.add({
title: 'Location not found',
description: 'Please try a different search term',
color: 'red'
})
return
}
if (!result) {
// No results found
toast.add({
title: 'No results',
description: 'Try being more specific',
color: 'amber'
})
}
}
</script>
API Rate Limits
Monitor your Mapbox usage:
- Free tier: 50,000 map loads/month, 100,000 geocoding requests/month
- Check usage: https://account.mapbox.com/
<script setup lang="ts">
// Debounce geocoding requests
import { useDebounceFn } from '@vueuse/core'
const { geocode } = useGeocode()
const debouncedGeocode = useDebounceFn(async (query: string) => {
await geocode(query)
}, 500) // Wait 500ms after user stops typing
</script>
Accessibility
Add proper labels and ARIA attributes:
<template>
<div role="region" aria-label="Interactive map">
<CroutonMapsMap
:center="center"
:zoom="12"
height="500px"
>
<template #default="{ map }">
<CroutonMapsMarker
v-for="location in locations"
:key="location.id"
:map="map"
:position="location.position"
:aria-label="`Location: ${location.name}`"
/>
</template>
</CroutonMapsMap>
</div>
</template>
Troubleshooting
Map Not Displaying
Symptoms: Blank container or loading spinner never disappears.
Solutions:
- Check access token in
.env:echo $MAPBOX_TOKEN - Verify runtime config:
const config = useMapConfig() console.log('Has token:', !!config.accessToken) - Ensure container has explicit height:
<CroutonMapsMap height="500px" /> <!-- ✅ Good --> <CroutonMapsMap /> <!-- ❌ Bad - no height --> - Check browser console for errors
Geocoding Failures
Symptoms: useGeocode() returns null or errors.
Solutions:
- Verify API limits: https://account.mapbox.com/
- Check network tab for 401/403 errors
- Validate query formatting:
// ✅ Good await geocode('123 Main St, San Francisco, CA')
// ❌ Bad await geocode('') // Empty query
### TypeScript Errors
**Symptoms:** Type errors with Mapbox GL types.
**Solutions:**
1. Install peer dependencies:
```bash
pnpm add -D mapbox-gl
- Import types from package:
import type { LngLatLike } from '@friendlyinternet/nuxt-crouton-maps'
Performance Issues
Symptoms: Slow rendering with many markers.
Solutions:
- Implement marker filtering based on zoom level
- Use marker clustering (consider future addon)
- Lazy load map component:
<script setup lang="ts"> const MapComponent = defineAsyncComponent(() => import('#components').then(c => c.CroutonMapsMap) ) </script>
Roadmap
Planned features for future releases:
- Marker clustering for large datasets
- Drawing tools (polygons, circles, lines)
- Heatmap layer support
- 3D terrain and buildings
- Custom marker icons
- Route/directions integration
- GeoJSON layer support
- MapLibre GL JS option (non-Mapbox alternative)
Resources
- Mapbox Documentation: https://docs.mapbox.com/mapbox-gl-js/
- GitHub Issues: https://github.com/pmcp/nuxt-crouton/issues
- Package: https://www.npmjs.com/package/@friendlyinternet/nuxt-crouton-maps
Related Documentation
- Collections & Layers - Collection basics
- Data Operations - Working with data
- Custom Components - Form customization