Awesome-claude-skills mapbox-odp-maps
Build interactive maps with Mapbox GL JS and ODP geodata in Next.js — covers setup, dynamic imports, CSS loading, geometry conversion, layer management, and common pitfalls
git clone https://github.com/joevstaas/awesome-claude-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/joevstaas/awesome-claude-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/mapbox-odp-maps" ~/.claude/skills/joevstaas-awesome-claude-skills-mapbox-odp-maps && rm -rf "$T"
skills/mapbox-odp-maps/SKILL.mdMapbox GL + ODP Maps Skill
Use this skill when the user wants to build or debug interactive maps that display geodata from the Ocean Data Platform (ODP) using Mapbox GL JS in a Next.js application.
Prerequisites
npm Dependencies
npm install mapbox-gl wkx apache-arrow npm install -D @types/geojson
| Package | Purpose |
|---|---|
| Map rendering engine |
| Convert WKT/WKB geometry (from ODP) to GeoJSON |
| Parse Arrow IPC binary format from ODP tabular API |
Environment Variables
NEXT_PUBLIC_MAPBOX_TOKEN=pk.your_token_here ODP_API_KEY=sk_your_key_here
NEXT_PUBLIC_MAPBOX_TOKEN is client-side (public). ODP_API_KEY is server-side only.
Next.js Setup — Critical Steps
1. next.config.ts
Mapbox GL and Apache Arrow need special configuration:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { transpilePackages: ["mapbox-gl"], serverExternalPackages: ["apache-arrow"], };
— required because mapbox-gl ships untranspiled ESMtranspilePackages: ["mapbox-gl"]
— Apache Arrow has native bindings that break in bundlingserverExternalPackages: ["apache-arrow"]
2. Mapbox GL CSS — Import in the map component
Load CSS via a direct import inside the map component file:
// components/map/map-view.tsx "use client"; import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css";
This works reliably with the manual client-only import pattern (see step 3). Do NOT add a
tag to layout.tsx — Next.js 16 with Turbopack can error with "Missing <head>
<html> and <body> tags" if you add <head> manually in the root layout.
3. Client-Only Import Pattern — Do NOT use next/dynamic
IMPORTANT (Next.js 16 / Turbopack): Do NOT use
next/dynamic with ssr: false for the map component. In Next.js 16 with Turbopack, dynamic() with ssr: false triggers a false-positive runtime error: "Missing <html> and <body> tags in the root layout." This is caused by the SSR bailout mechanism confusing the Turbopack dev overlay.
Instead, use a manual
+ lazy useEffect
pattern:import()
// components/map/index.tsx (wrapper — client-only without next/dynamic) "use client"; import { useEffect, useState, type ComponentType } from "react"; export function MapView() { const [Component, setComponent] = useState<ComponentType | null>(null); useEffect(() => { import("./map-view").then((mod) => setComponent(() => mod.default)); }, []); if (!Component) { return ( <div className="flex h-full w-full items-center justify-center bg-slate-100"> <div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> </div> ); } return <Component />; }
// components/map/map-view.tsx (actual map) "use client"; import { useEffect, useRef } from "react"; import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css"; export default function MapView() { const mapContainer = useRef<HTMLDivElement>(null); const map = useRef<mapboxgl.Map | null>(null); // ... }
Import the wrapper (not the implementation) in pages:
import { MapView } from "@/components/map";
4. Root Layout — Keep it minimal
Do NOT add
<head>, Google Fonts, or <link> tags to the root layout. Next.js 16 Turbopack is strict about the root layout structure. Keep it simple:
// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "My Map App", description: "...", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className="antialiased">{children}</body> </html> ); }
5. Map Container Sizing
The map container MUST have explicit dimensions. Use
h-full w-full with a parent that has a defined height:
// In the map component return ( <div className="relative h-full w-full"> <div ref={mapContainer} className="h-full w-full" /> {/* Overlays go here */} </div> ); // In the page — parent MUST have height <div className="relative flex-1"> <MapView /> </div>
Common pitfall: Using
absolute inset-0 on the map container. This can cause the map canvas to render at the wrong size. Use h-full w-full instead.
Height chain: Ensure every ancestor up to the page root has a defined height. Typical pattern:
div.h-screen.flex.flex-col header.flex-shrink-0 main.flex-1.overflow-hidden ← use relative here MapView ← h-full w-full
ODP → GeoJSON Pipeline
Architecture Overview
ODP Tabular API (Arrow IPC binary) → apache-arrow (parse to rows) → wkx (WKT/WKB geometry → GeoJSON) → GeoJSON FeatureCollection → Next.js API route (serves JSON) → Mapbox GL (renders on map)
Server-Side: ODP Client
// lib/odp-client.ts const ODP_BASE_URL = "https://api.hubocean.earth"; class ODPClient { private apiKey: string; constructor(apiKey: string) { this.apiKey = apiKey; } async queryTabularData(datasetId: string, options: { query?: string; sample?: number; columns?: string[]; } = {}): Promise<ArrayBuffer> { const body: Record<string, unknown> = {}; if (options.query) body.query = options.query; if (options.sample) body.sample = options.sample; if (options.columns) body.columns = options.columns; const response = await fetch( `${ODP_BASE_URL}/api/table/v2/sdk/select?table_id=${datasetId}`, { method: "POST", headers: { Authorization: `ApiKey ${this.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(body), } ); if (!response.ok) { const text = await response.text().catch(() => "Unknown error"); throw new Error(`ODP API error: ${response.status} - ${text}`); } return response.arrayBuffer(); } }
Key details:
- Auth header:
(NOTApiKey {key}
)Bearer - Tabular endpoint:
POST /api/table/v2/sdk/select?table_id={uuid} - Request body uses
,query
,sample
(NOTcolumns
,filter
)limit - Response is Apache Arrow IPC binary (NOT JSON)
Server-Side: Arrow Parser
// lib/arrow-parser.ts import * as Arrow from "apache-arrow"; export function parseArrowIPC(buffer: ArrayBuffer): Record<string, unknown>[] { const table = Arrow.tableFromIPC(buffer); const data: Record<string, unknown>[] = []; for (let i = 0; i < table.numRows; i++) { const row: Record<string, unknown> = {}; for (const field of table.schema.fields) { const col = table.getChild(field.name); let val = col ? col.get(i) ?? null : null; // Convert BigInt to Number for JSON serialization if (typeof val === "bigint") val = Number(val); // Convert NaN to null if (typeof val === "number" && isNaN(val)) val = null; row[field.name] = val; } data.push(row); } return data; }
Important: Arrow may return
BigInt for integer columns (breaks JSON.stringify) and NaN for missing values. Always convert both.
Server-Side: Geometry Conversion
ODP stores geometry as WKT or WKB strings. Convert to GeoJSON:
// lib/geometry-utils.ts import wkx from "wkx"; export function toGeoJsonGeometry(value: unknown): GeoJSON.Geometry | null { if (!value) return null; try { if (value instanceof Uint8Array || value instanceof Buffer) { const geom = wkx.Geometry.parse(Buffer.from(value)); return geom.toGeoJSON() as GeoJSON.Geometry; } if (typeof value === "string") { // Try WKT first if (value.startsWith("POINT") || value.startsWith("POLYGON") || value.startsWith("LINE") || value.startsWith("MULTI") || value.startsWith("GEOMETRY")) { const geom = wkx.Geometry.parse(value); return geom.toGeoJSON() as GeoJSON.Geometry; } // Try hex-encoded WKB const geom = wkx.Geometry.parse(Buffer.from(value, "hex")); return geom.toGeoJSON() as GeoJSON.Geometry; } } catch { return null; } return null; }
Server-Side: API Route
// app/api/datasets/[id]/route.ts import { NextRequest, NextResponse } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const odpUuid = DATASET_UUIDS[id]; if (!odpUuid) return NextResponse.json({ error: "Unknown dataset" }, { status: 404 }); const client = getODPClient(); const buffer = await client.queryTabularData(odpUuid, { sample: 50000 }); const parsed = parseArrowIPC(buffer); const features = parsed.data.map((row) => ({ type: "Feature" as const, geometry: toGeoJsonGeometry(row.geometry), properties: Object.fromEntries( Object.entries(row).filter(([k]) => k !== "id" && k !== "geometry") ), })); return NextResponse.json({ type: "FeatureCollection", features }); }
Client-Side: Loading Data into Mapbox
// In the map component, inside map.on("load", async () => { ... }) const res = await fetch(`/api/datasets/${datasetId}`); const geojson = await res.json(); map.current.addSource("observations", { type: "geojson", data: geojson, }); const geomType = geojson.features[0]?.geometry?.type; if (geomType === "Point" || geomType === "MultiPoint") { map.current.addLayer({ id: "points", type: "circle", source: "observations", paint: { "circle-color": color, "circle-radius": 7, "circle-stroke-width": 2, "circle-stroke-color": "#fff", }, }); } else { // Polygons map.current.addLayer({ id: "polygons", type: "fill", source: "observations", paint: { "fill-color": color, "fill-opacity": 0.5, }, }); map.current.addLayer({ id: "polygons-outline", type: "line", source: "observations", paint: { "line-color": color, "line-width": 1.5, }, }); }
Popup Styling
When using
mapboxgl.Popup with .setHTML(), Mapbox applies its own default styles which can result in very low contrast text (light gray on white). Always set explicit text colors on popup content:
const html = ` <div style="font-family: system-ui, sans-serif; max-width: 260px;"> <h3 style="margin: 0 0 4px; font-size: 15px; color: #1a1a2e;"> ${title} </h3> <p style="margin: 0 0 8px; font-size: 12px; color: #666; font-style: italic;"> ${subtitle} </p> <table style="font-size: 12px; border-collapse: collapse; width: 100%;"> <tr> <td style="padding: 2px 8px 2px 0; color: #888;">Label</td> <td style="color: #333;">Value</td> </tr> </table> </div> `;
Key rules:
- Labels (left column):
for muted appearancecolor: #888 - Values (right column):
for readable dark textcolor: #333 - Title:
for strong heading contrastcolor: #1a1a2e - Never rely on inherited/default text color in popups
Caching Strategy
ODP data changes infrequently. Cache GeoJSON responses server-side:
const cache = new Map<string, { data: unknown; expires: number }>(); const TTL = 60 * 60 * 1000; // 1 hour function getCached<T>(key: string): T | null { const entry = cache.get(key); if (entry && Date.now() < entry.expires) return entry.data as T; cache.delete(key); return null; } function setCache(key: string, data: unknown) { cache.set(key, { data, expires: Date.now() + TTL }); }
Note: In-memory cache works for dev and serverless (within a single invocation). For Vercel serverless, consider that each cold start resets the cache. For persistent caching, use Vercel KV or write to
/tmp.
Stale Closures in Map Event Handlers
Critical bug pattern: Mapbox event handlers are registered once during map initialization and capture the closure at that point. If a callback prop changes (e.g., due to React state updates), the map handler still calls the stale version.
Problem:
// BAD — click handler captures initial onStationSelect and never updates map.current.on("click", layerId, (e) => { onStationSelect(e.features[0]); // stale closure! });
Solution — use a ref:
// Keep a stable ref that always points to the latest callback const onStationSelectRef = useRef(onStationSelect); onStationSelectRef.current = onStationSelect; // In the map init effect, read from the ref map.current.on("click", layerId, (e) => { onStationSelectRef.current(e.features[0]); // always fresh });
This is especially important when the callback depends on changing state (e.g., a "compare mode" toggle).
Null Safety After Async Operations
When loading data inside
useEffect with async/await, always check map.current after the await — the component may have unmounted and the map destroyed while waiting.
const loadData = async () => { const res = await fetch("/api/data"); // async gap const geojson = await res.json(); if (!map.current) return; // map may be gone! map.current.addSource("data", { type: "geojson", data: geojson }); };
Avoid
— non-null assertions hide this bug. Use null checks instead.map.current!
Security Headers on Vercel
next.config.ts headers() and Next.js middleware may NOT reliably set response headers on Vercel for cached/statically prerendered pages. Use vercel.json instead:
{ "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Frame-Options", "value": "DENY" }, { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline' https://api.mapbox.com; img-src 'self' data: blob: https://*.mapbox.com; connect-src 'self' https://*.mapbox.com https://events.mapbox.com; worker-src 'self' blob:; child-src blob:; frame-src 'none'; object-src 'none'" } ] } ] }
The CSP must whitelist Mapbox domains for tiles, styles, workers, and telemetry.
Common Pitfalls
Next.js 16 / Turbopack issues
- "Missing html/body tags" false positive: Caused by using
withnext/dynamic
. Use the manualssr: false
+useEffect
pattern instead (see step 3 above).import()
in root layout: Do NOT add an explicit<head>
tag in the root layout — Turbopack can misparse it and throw the "Missing html/body" error. Use<head>
export or load CSS via component imports.metadata- Stale Turbopack cache: When changing layout.tsx or env vars, always
and restart the dev server. Turbopack caches aggressively.rm -rf .next
Map is invisible / not rendering
- CSS not loaded: With the manual import pattern,
in map-view.tsx works reliably. Verify with browser DevTools that mapbox styles are present.import "mapbox-gl/dist/mapbox-gl.css" - Container has no height: Every ancestor must have explicit height. Check with browser DevTools → computed styles.
- Version mismatch: If using CDN CSS, version must match
version.npm ls mapbox-gl
WebGL / canvas issues
- Canvas renders at wrong size: Use
on the map container div, NOTh-full w-full
. Mapbox calculates canvas size from container dimensions.absolute inset-0 - Map.resize() needed: If the container resizes after map init (e.g., panel toggle), call
.map.resize()
ODP data issues
- Auth header format: Use
, notApiKey {key}
.Bearer {key} - Tabular API endpoint:
, NOTPOST /api/table/v2/sdk/select?table_id={uuid}
./data/{uuid} - Binary response: ODP returns Apache Arrow IPC, not JSON. Must parse with
library.apache-arrow - Geometry column: Contains WKT or WKB — must convert to GeoJSON before Mapbox can render it.
- NaN/null values: Arrow data often contains NaN for missing values. Check with
.typeof val === "number" && isNaN(val) - BigInt values: Arrow may return BigInt for integer columns. Convert with
before JSON serialization.Number(val)
Popup issues
- Low contrast text: Mapbox popup default styles can make text nearly invisible. Always set explicit
on all text elements inside popup HTML (see Popup Styling section).color
Event handler issues
- Stale closure in click/hover handlers: Map event handlers capture the closure at registration time. If callback props change later, handlers use stale values. Use a
to always read the latest callback (see "Stale Closures" section above).useRef - Null map after async: After any
inside a mapawait
, checkuseEffect
— the map may have been destroyed. Never useif (!map.current) return
.map.current!
Vercel deployment
- Serverless timeout: Hobby plan has 10s timeout. Large ODP datasets may exceed this. Use
parameter to limit rows.sample - Bundle size:
is large. Keep it server-side only withapache-arrow
.serverExternalPackages - Security headers not applied:
and middleware may not set headers on cached pages. Usenext.config.ts headers()
headers instead (see "Security Headers on Vercel" section).vercel.json
Debugging Checklist
When a map doesn't render, check in this order:
// Add to map init useEffect: useEffect(() => { if (!mapContainer.current) { console.error("[Map] Container ref is null"); return; } const rect = mapContainer.current.getBoundingClientRect(); console.log("[Map] Container:", rect.width, "x", rect.height); // Both must be > 0 map.current.on("load", () => { console.log("[Map] Loaded OK"); const canvas = mapContainer.current?.querySelector("canvas"); if (canvas) { console.log("[Map] Canvas:", canvas.width, "x", canvas.height); } }); map.current.on("error", (e) => { console.error("[Map] Error:", e.error?.message || e); }); }, []);
- Container dimensions > 0? If not → fix CSS height chain
- Map "load" event fires? If not → check token, check network for tile 401/403
- Canvas dimensions match container (2x on retina)? If not → CSS issue
- No errors in console? Check for WebGL context lost, token errors