What if you could see everyone on your platform, right where they are in the world? Not as dots on a flat map, but as glowing points on a slowly rotating earth.
I'm building Arc Accountability - a social platform where people share honest journeys. The marquee feature is a 3D interactive globe that shows public users as warm amber dots at their real geographic locations. You can hover to see names, click to see profiles, and just... watch the earth turn.
Here's how I built it.
Why a Globe Instead of a Map
A flat map is utilitarian. It says "here's where people are." A globe says something different. It rotates slowly. You watch it. You notice a dot in São Paulo, another in Tokyo, a cluster in Berlin. There's a contemplative quality to it that a Google Maps embed will never have.
The design intent was specific: warm off-white earth (not dark, not blue), amber dots (not red pins), subtle country outlines (not bright borders). The whole thing should feel quiet. Anti-performative. You're not competing with anyone on this globe - you're just present on it.
The Stack
Four pieces make this work:
- Next.js 16 (App Router) - server component fetches profiles from Supabase, passes them to a client component that renders the globe
- three-globe - a Three.js library that handles the hard parts: sphere geometry, country polygons, point plotting, coordinate math
- topojson-client + world-atlas - country border data as TopoJSON, converted to GeoJSON at runtime
- Nominatim (OpenStreetMap) - free geocoding API, no API key needed. Converts "Melbourne, Australia" to lat/lng when users set their city
The Architecture
The globe needs WebGL, which means it can't render on the server. So the setup is a three-layer split:
Layer 1: Server Component (globe/page.tsx) - fetches all public profiles from Supabase with their coordinates, mood data, and activity counts. This runs at request time on the server.
Layer 2: Dynamic Import Wrapper (globe-client.tsx) - a thin client component that uses Next.js dynamic() with ssr: false:
const GlobeView = dynamic(
() => import('@/components/globe-view').then((m) => m.GlobeView),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full">
<div className="w-16 h-16 rounded-full bg-arc-amber/10 animate-pulse" />
<p className="text-sm text-arc-text-muted">Loading the globe...</p>
</div>
),
}
)
This prevents Three.js from being imported during SSR (where window and WebGLRenderingContext don't exist). The loading fallback shows a pulsing amber circle while the ~200KB of Three.js downloads.
Layer 3: The Globe Component (globe-view.tsx) - the actual Three.js scene. All the rendering, interaction, and state management lives here.
Code Walkthrough: Building the Globe
The entire globe lives in one useEffect. Everything is dynamically imported inside it:
useEffect(() => {
let cancelled = false
const cleanupFns: (() => void)[] = []
async function init() {
const [
{ Scene, PerspectiveCamera, WebGLRenderer, AmbientLight,
DirectionalLight, Color, Raycaster, Vector2 },
{ default: ThreeGlobe },
{ OrbitControls },
topojson,
] = await Promise.all([
import('three'),
import('three-globe'),
import('three/examples/jsm/controls/OrbitControls.js'),
import('topojson-client'),
])
if (cancelled || !containerRef.current) return
// ... scene setup
}
init()
return () => {
cancelled = true
// cleanup
}
}, [])
The cancelled flag prevents state updates if the component unmounts before all those imports resolve. The cleanupFns array collects every event listener and disposable so the cleanup function can tear everything down.
Scene, Camera, Renderer
Standard Three.js boilerplate with some specific choices:
const renderer = new WebGLRenderer({
antialias: true,
alpha: true, // transparent background
powerPreference: 'high-performance',
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) // cap at 2x
renderer.setClearColor(0x000000, 0) // fully transparent
alpha: true and a transparent clear color let the globe float over whatever background the page has. Capping pixel ratio at 2 prevents performance issues on high-DPI displays.
The camera sits at z = 280 with a 50-degree field of view, and OrbitControls handle mouse/touch interaction:
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.1
controls.rotateSpeed = 0.4
controls.minDistance = 180
controls.maxDistance = 500
controls.enablePan = false
controls.autoRotate = true
controls.autoRotateSpeed = 0.3
Auto-rotation at 0.3 speed is barely perceptible. The globe drifts slowly enough that it feels alive without being distracting. Panning is disabled because the globe should always stay centered.
The Warm Palette
This is where it stops looking like every other three-globe demo:
const globeMaterial = globe.globeMaterial() as any
globeMaterial.color = new Color('#EDE9E3') // warm off-white surface
globeMaterial.emissive = new Color('#E8E3DC') // subtle self-illumination
globeMaterial.emissiveIntensity = 0.08
globeMaterial.shininess = 5 // matte, not glossy
globe.showAtmosphere(true)
globe.atmosphereColor('#E8A849') // amber glow at edges
globe.atmosphereAltitude(0.15)
Warm ambient light (0xfaf0e6, intensity 1.2) and a slightly warm directional light (0xfff5e6) complete the look. The atmosphere is tinted amber to match the dot color, creating a cohesive warmth.
Country polygons get extremely subtle styling:
globe
.polygonsData(countries.features)
.polygonCapColor(() => 'rgba(212, 208, 202, 0.3)')
.polygonSideColor(() => 'rgba(212, 208, 202, 0.1)')
.polygonStrokeColor(() => 'rgba(200, 195, 187, 0.2)')
.polygonAltitude(0.005)
At 0.3 opacity for fills and 0.2 for strokes, the countries are just barely visible. You can tell continents apart, but they don't steal attention from the user dots.
Plotting Users
Each public profile with coordinates becomes an amber dot:
const pointsData = publicProfiles
.filter((p) => p.latitude != null && p.longitude != null)
.map((p) => ({
lat: p.latitude!,
lng: p.longitude!,
size: 0.4,
color: '#E8A849',
profileId: p.id,
}))
globe
.pointsData(pointsData)
.pointLat('lat')
.pointLng('lng')
.pointColor('color')
.pointAltitude(0.01)
.pointRadius('size')
.pointResolution(8)
pointResolution(8) gives each dot enough geometry to look smooth while keeping the triangle count reasonable.
Click Detection with Raycaster
Three.js doesn't have built-in "click on this 3D object" support. You need a Raycaster - it shoots a ray from the camera through the mouse position and reports what it hits:
function onPointerDown(event: PointerEvent) {
const rect = renderer.domElement.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(globe.children, true)
if (intersects.length > 0) {
const hitPoint = intersects[0].point
const hitGeo = globe.toGeoCoords({
x: hitPoint.x, y: hitPoint.y, z: hitPoint.z,
})
// Find nearest profile within ~5 degrees
let nearestId: string | null = null
let minDist = Infinity
for (const pt of pointsData) {
const d = Math.pow(pt.lat - hitGeo.lat, 2)
+ Math.pow(pt.lng - hitGeo.lng, 2)
if (d < minDist) { minDist = d; nearestId = pt.profileId }
}
if (nearestId && minDist < 25) {
const match = profilesRef.current.find((p) => p.id === nearestId)
if (match) setSelectedRef.current(match)
}
}
}
The key insight: globe.toGeoCoords() converts a 3D hit point back to latitude/longitude. Then a simple nearest-neighbor search over the profile points finds who got clicked. The threshold of minDist < 25 (squared distance, roughly 5 degrees) gives a generous click target without triggering on random ocean clicks.
The hover handler uses the same raycaster logic but updates a tooltip that follows the cursor:
{hoveredProfile && hoverPos && !selectedProfile && (
<div
className="fixed z-50 pointer-events-none"
style={{
left: hoverPos.x + 12,
top: hoverPos.y - 10,
transform: 'translateY(-100%)'
}}
>
<div className="bg-white rounded-lg shadow-lg border px-3 py-2">
<p className="text-sm font-medium">{hoveredProfile.display_name}</p>
{hoveredProfile.city && (
<p className="text-xs text-muted flex items-center gap-1">
<MapPin className="w-3 h-3" /> {hoveredProfile.city}
</p>
)}
</div>
</div>
)}
City Geocoding with Nominatim
Users need to set their city so we can plot them. We use OpenStreetMap's Nominatim API - free, no API key, no rate limit hassle for our scale.
The CityAutocomplete component is a combobox with debounced search:
const searchCities = useCallback(async (searchQuery: string) => {
if (searchQuery.trim().length < 2) {
setResults([])
return
}
const res = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`q=${encodeURIComponent(searchQuery)}` +
`&format=json&limit=5&addressdetails=1`,
{ headers: { 'User-Agent': 'ArcAccountability/1.0' } }
)
const data: NominatimResult[] = await res.json()
setResults(data)
}, [])
A few things to note:
-
Debounced at 300ms - we don't hit the API on every keystroke. A
setTimeoutref handles this. - User-Agent header - Nominatim's usage policy asks for this. Be a good citizen.
- addressdetails=1 - gives us structured address components so we can display "Melbourne, Australia" instead of "Melbourne, City of Melbourne, Victoria, Australia, 3000".
The label extraction function handles the many ways Nominatim structures addresses:
function getCityLabel(result: NominatimResult): string {
const addr = result.address
if (!addr) {
const parts = result.display_name.split(', ')
return parts.slice(0, 2).join(', ')
}
const city = addr.city || addr.town || addr.village
|| addr.municipality || addr.county || ''
const country = addr.country || ''
if (city && country) return `${city}, ${country}`
if (city) return city
const parts = result.display_name.split(', ')
return parts.slice(0, 2).join(', ')
}
When a user selects a city, we store the label, latitude, and longitude in their profile. The geocoding happens once at profile-save time, not on every page load.
Supabase Integration
The server component fetches everything in parallel:
export default async function GlobePage() {
const supabase = await createClient()
const { data: profiles } = await supabase
.from('profiles')
.select('id, display_name, city, latitude, longitude')
.eq('is_public', true)
.not('latitude', 'is', null)
// Batch fetch mood data and activity counts
const profileIds = profiles.map((p) => p.id)
const [entriesResult, goalCountsResult] = await Promise.all([
supabase
.from('journal_entries')
.select('user_id, mood_tag, created_at')
.in('user_id', profileIds)
.eq('is_public', true)
.order('created_at', { ascending: false }),
supabase
.from('goals')
.select('user_id')
.in('user_id', profileIds),
])
// Build lookup maps for O(n) assembly
const latestMood = new Map<string, MoodTag | null>()
const updateCount = new Map<string, number>()
for (const entry of entriesResult.data ?? []) {
updateCount.set(entry.user_id,
(updateCount.get(entry.user_id) ?? 0) + 1)
if (!latestMood.has(entry.user_id)) {
latestMood.set(entry.user_id, entry.mood_tag as MoodTag | null)
}
}
// ... assemble PublicProfile objects
}
Three queries, two in parallel. The entries query is ordered by created_at descending, so the first entry per user is their latest mood - no need for a subquery or window function.
Row Level Security on Supabase handles the privacy layer. Only profiles where is_public = true are returned. Even if someone inspected the network tab, they'd only see what the user chose to share.
Design Decisions
Why warm off-white instead of dark? Every 3D globe demo on the internet uses a dark background with glowing neon continents. That aesthetic screams "tech demo." We wanted something that feels warm and human. The off-white earth with amber accents matches the rest of the app's design language - it's a feature, not a showpiece.
Why subtle country outlines? At full opacity, country borders dominate the visual. The countries aren't the point. The people are. So the borders exist just enough to give geographic context.
Why amber for every dot? We considered color-coding dots by mood (each user has a mood state). But that turns the globe into a data visualization. Color-coding creates implicit hierarchy - "green dots are better than red dots." That's the opposite of what the platform represents. Everyone gets the same amber. You're all just here.
Why auto-rotation? A static globe looks dead. The slow rotation (0.3 speed, barely moving) gives it life. It also means users who just visit the page and watch will gradually see the whole world, not just whatever face loaded first.
Cleanup Matters
Three.js is notorious for memory leaks in React. The cleanup function is thorough:
return () => {
cancelled = true
if (frameRef.current) cancelAnimationFrame(frameRef.current)
if (rendererRef.current) {
rendererRef.current.dispose()
if (container && rendererRef.current.domElement.parentNode === container) {
container.removeChild(rendererRef.current.domElement)
}
}
cleanupFns.forEach((fn) => fn())
}
The cleanupFns array collects everything that needs teardown: event listeners, OrbitControls, the globe destructor. Without this, navigating away and back would create duplicate canvases and leak GPU memory.
What I'd Do Differently
If I were starting fresh:
- React Three Fiber instead of raw Three.js - better React integration, automatic cleanup, declarative scene description. I went vanilla because three-globe's API is imperative anyway, but R3F would simplify the lifecycle management.
- Web Workers for geocoding - right now Nominatim calls happen on the main thread. For a city search that's fine, but batch geocoding would benefit from a worker.
- Clustering - with thousands of users, individual dots would overlap. I'd add spatial clustering that merges nearby dots into a single larger dot with a count badge.
The globe works well for our current scale. If you're building something similar, the three-globe + Next.js dynamic import pattern is solid. The main thing to get right is the aesthetic - most of the code is setup, but the difference between "cool demo" and "production feature" is in the material colors, the lighting, and the interaction details.
I build production AI systems and interactive web experiences. astraedus.dev
Top comments (0)