# How to Build Map application with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build an interactive map application with V0 using Next.js, Supabase with PostGIS, react-map-gl, and shadcn/ui. Features dynamic markers, radius-based search, location detail panels, and user favorites — with proper SSR handling for Mapbox GL. Takes about 1-2 hours to complete.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project with PostGIS extension enabled (free tier works)
- A Mapbox account for the map token (free tier: 50K map loads/month)
- Location data with latitude and longitude coordinates

## Step-by-step guide

### 1. Set up Supabase with PostGIS and location schema

Connect Supabase via the Connect panel, enable the PostGIS extension, and create the locations schema with spatial columns and indexes for fast radius queries.

```
// Paste this prompt into V0's AI chat:
// Build a map application. First, I need to set up Supabase with spatial queries.
// Create this SQL migration:
// 1. Enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;
// 2. Create locations table: id (uuid PK), name (text), description (text), category (text), latitude (numeric), longitude (numeric), address (text), metadata (jsonb), image_url (text), created_at (timestamptz)
// 3. Add a geography column: ALTER TABLE locations ADD COLUMN geog geography(Point, 4326);
// 4. Create trigger to auto-update geog from lat/lng on insert/update
// 5. Create spatial index: CREATE INDEX idx_locations_geog ON locations USING GIST (geog);
// 6. Create user_favorites table: user_id (uuid FK to auth.users), location_id (uuid FK to locations), PRIMARY KEY (user_id, location_id)
// Add RLS policies: anyone can read locations, authenticated users manage favorites.
```

> Pro tip: Enable PostGIS in Supabase Dashboard under Database > Extensions before running the migration. Without it, the geography column and spatial functions will fail.

**Expected result:** PostGIS is enabled, the locations table has a spatial index on the geography column, and the favorites table is ready.

### 2. Build the radius search API with PostGIS

Create the API route that accepts latitude, longitude, and radius parameters and returns nearby locations using PostGIS ST_DWithin for efficient spatial queries.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const lat = parseFloat(searchParams.get('lat') || '0')
  const lng = parseFloat(searchParams.get('lng') || '0')
  const radius = parseInt(searchParams.get('radius') || '5000')
  const category = searchParams.get('category')

  let query = supabase.rpc('nearby_locations', {
    p_lat: lat,
    p_lng: lng,
    p_radius_meters: radius,
  })

  if (category) {
    query = query.eq('category', category)
  }

  const { data, error } = await query.limit(50)

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data })
}
```

**Expected result:** The API returns locations within the specified radius, sorted by distance. Spatial index makes queries fast even with thousands of locations.

### 3. Create the map component with SSR-safe dynamic import

Build the interactive map using react-map-gl wrapped in a 'use client' component. Import it dynamically with next/dynamic and ssr: false to prevent server-side rendering errors from Mapbox GL's window dependency.

```
'use client'

import { useState, useCallback } from 'react'
import Map, { Marker, Popup } from 'react-map-gl'
import 'mapbox-gl/dist/mapbox-gl.css'

interface Location {
  id: string
  name: string
  category: string
  latitude: number
  longitude: number
  address: string
}

interface MapViewProps {
  locations: Location[]
  onSelect: (location: Location) => void
}

export function MapView({ locations, onSelect }: MapViewProps) {
  const [popup, setPopup] = useState<Location | null>(null)

  return (
    <Map
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      initialViewState={{ longitude: -73.98, latitude: 40.75, zoom: 12 }}
      style={{ width: '100%', height: '100%' }}
      mapStyle="mapbox://styles/mapbox/light-v11"
    >
      {locations.map((loc) => (
        <Marker
          key={loc.id}
          longitude={loc.longitude}
          latitude={loc.latitude}
          onClick={(e) => {
            e.originalEvent.stopPropagation()
            setPopup(loc)
            onSelect(loc)
          }}
        />
      ))}
      {popup && (
        <Popup
          longitude={popup.longitude}
          latitude={popup.latitude}
          onClose={() => setPopup(null)}
        >
          <div className="p-2">
            <h3 className="font-semibold">{popup.name}</h3>
            <p className="text-sm text-muted-foreground">{popup.address}</p>
          </div>
        </Popup>
      )}
    </Map>
  )
}
```

> Pro tip: Store NEXT_PUBLIC_MAPBOX_TOKEN in V0's Vars tab with the NEXT_PUBLIC_ prefix — Mapbox tokens are publishable keys designed to be used in the browser.

**Expected result:** The map renders with dynamic markers for each location. Clicking a marker shows a popup and triggers the detail panel.

### 4. Build the main map page with sidebar and dynamic import

Create the full map page that combines the server-loaded location data, dynamically imported map component, sidebar listing, and search autocomplete. The parent is a Server Component that passes data to client children.

```
// Paste this prompt into V0's AI chat:
// Create a map application page at app/map/page.tsx.
// Requirements:
// - Server Component that fetches initial locations from Supabase
// - Dynamically import the MapView component with next/dynamic and { ssr: false }
// - Layout: full-screen map on the right (70% width), sidebar on the left (30% width)
// - Sidebar has: Command palette search at top for finding locations by name
// - Below search: ScrollArea with location Cards showing name, category Badge, address, distance
// - Clicking a sidebar Card highlights the marker on the map
// - Clicking a marker opens a shadcn/ui Sheet slide-over with full location details: image, description, metadata, category Badge, address, and a favorite toggle Button (heart icon)
// - Add category filter as a row of Badge-style toggle buttons above the sidebar list
// - Add Skeleton loading states for the map and sidebar while data loads
// - Mobile layout: map full-screen with a bottom Sheet for the sidebar
```

**Expected result:** The map page shows an interactive map with a sidebar listing, search autocomplete, category filters, and a location detail Sheet.

### 5. Add the PostGIS radius search function and deploy

Create the Supabase RPC function for spatial queries, add the favorites Server Action, and deploy the application.

```
-- Run this in Supabase SQL Editor
CREATE OR REPLACE FUNCTION nearby_locations(
  p_lat double precision,
  p_lng double precision,
  p_radius_meters int DEFAULT 5000
)
RETURNS TABLE (
  id uuid,
  name text,
  description text,
  category text,
  latitude numeric,
  longitude numeric,
  address text,
  metadata jsonb,
  image_url text,
  distance_meters double precision
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    l.id, l.name, l.description, l.category,
    l.latitude, l.longitude, l.address,
    l.metadata, l.image_url,
    ST_Distance(
      l.geog,
      ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography
    ) AS distance_meters
  FROM locations l
  WHERE ST_DWithin(
    l.geog,
    ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
    p_radius_meters
  )
  ORDER BY distance_meters;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```

**Expected result:** The RPC function returns locations within the specified radius sorted by distance. Deploy via Share > Publish in V0.

## Complete code example

File: `app/api/locations/nearby/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const lat = parseFloat(searchParams.get('lat') || '0')
  const lng = parseFloat(searchParams.get('lng') || '0')
  const radius = Math.min(
    parseInt(searchParams.get('radius') || '5000'),
    50000
  )
  const category = searchParams.get('category')

  const { data, error } = await supabase.rpc('nearby_locations', {
    p_lat: lat,
    p_lng: lng,
    p_radius_meters: radius,
  })

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  const filtered = category
    ? data?.filter((l: { category: string }) => l.category === category)
    : data

  return NextResponse.json({
    data: filtered,
    meta: { lat, lng, radius, count: filtered?.length || 0 },
  })
}
```

## Common mistakes

- **Importing react-map-gl in a Server Component without dynamic import** — Mapbox GL requires the window object which does not exist during server-side rendering. This causes a 'window is not defined' error. Fix: Use next/dynamic with { ssr: false } to dynamically import the map component only on the client side.
- **Not creating a spatial index on the geography column** — Without a GIST index, PostGIS spatial queries scan every row in the table. This is extremely slow with more than a few hundred locations. Fix: Create a GIST index: CREATE INDEX idx_locations_geog ON locations USING GIST (geog). This makes radius queries nearly instant.
- **Using NEXT_PUBLIC_ prefix for SUPABASE_SERVICE_ROLE_KEY** — The service role key bypasses all RLS policies. Exposing it in the browser means anyone can read, modify, or delete all location data. Fix: Use the service role key only in API routes without any prefix. The Mapbox token is safe with NEXT_PUBLIC_ because it is a publishable key.

## Best practices

- Use next/dynamic with { ssr: false } for the map component to avoid window-related SSR errors
- Enable PostGIS and create a GIST spatial index before deploying — radius queries need it for performance
- Store NEXT_PUBLIC_MAPBOX_TOKEN in V0's Vars tab — Mapbox tokens are publishable keys safe for client-side use
- Preload location data in the Server Component parent and pass it as props to the client map component
- Cap the radius parameter in the API route (e.g., max 50km) to prevent queries that scan the entire table
- Use V0's Design Mode (Option+D) to adjust the sidebar width, card layout, and map controls without spending credits
- Add Skeleton loading states for both the map and sidebar to prevent layout shifts during data loading

## Frequently asked questions

### Why do I need PostGIS for the map application?

PostGIS adds spatial query capabilities to PostgreSQL. Without it, finding locations within a radius requires calculating distances for every row in JavaScript — extremely slow with thousands of locations. PostGIS uses spatial indexes to do this in milliseconds.

### Is the Mapbox token safe to expose in the browser?

Yes. Mapbox tokens are publishable keys designed to be used in frontend code. You can restrict the token to specific URLs in the Mapbox Dashboard for additional security. Use NEXT_PUBLIC_MAPBOX_TOKEN in V0's Vars tab.

### How do I handle the SSR error with Mapbox GL?

Mapbox GL requires the window object which does not exist on the server. Use next/dynamic to import the map component with { ssr: false }, which ensures it only loads in the browser.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The map application has complex components (map, sidebar, search, detail panel) that require multiple prompts to build.

### Can I use Google Maps instead of Mapbox?

Yes, but react-map-gl wraps Mapbox GL which offers a generous free tier (50K loads/month). For Google Maps, use @vis.gl/react-google-maps instead. The Supabase PostGIS backend works the same regardless of which map provider you choose.

### How do I deploy the map application?

Click Share in V0, then Publish to Production. Make sure PostGIS is enabled in your Supabase project, the spatial index is created, and NEXT_PUBLIC_MAPBOX_TOKEN is set in the Vars tab.

### Can RapidDev help build a custom map application?

Yes. RapidDev has built over 600 apps including map-based platforms for real estate, delivery, and directory services with PostGIS, clustering, and routing. Book a free consultation to discuss your map requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/map-application
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/map-application
