diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 910e648..08d80d2 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -75,6 +75,11 @@ type ComplexityRoot struct {
 		Token   func(childComplexity int) int
 	}
 
+	Coordinates struct {
+		Latitude  func(childComplexity int) int
+		Longitude func(childComplexity int) int
+	}
+
 	FaceGroup struct {
 		ID             func(childComplexity int) int
 		ImageFaceCount func(childComplexity int) int
@@ -122,6 +127,7 @@ type ComplexityRoot struct {
 	MediaExif struct {
 		Aperture        func(childComplexity int) int
 		Camera          func(childComplexity int) int
+		Coordinates     func(childComplexity int) int
 		DateShot        func(childComplexity int) int
 		Exposure        func(childComplexity int) int
 		ExposureProgram func(childComplexity int) int
@@ -473,6 +479,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.AuthorizeResult.Token(childComplexity), true
 
+	case "Coordinates.latitude":
+		if e.complexity.Coordinates.Latitude == nil {
+			break
+		}
+
+		return e.complexity.Coordinates.Latitude(childComplexity), true
+
+	case "Coordinates.longitude":
+		if e.complexity.Coordinates.Longitude == nil {
+			break
+		}
+
+		return e.complexity.Coordinates.Longitude(childComplexity), true
+
 	case "FaceGroup.id":
 		if e.complexity.FaceGroup.ID == nil {
 			break
@@ -695,6 +715,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.MediaExif.Camera(childComplexity), true
 
+	case "MediaEXIF.coordinates":
+		if e.complexity.MediaExif.Coordinates == nil {
+			break
+		}
+
+		return e.complexity.MediaExif.Coordinates(childComplexity), true
+
 	case "MediaEXIF.dateShot":
 		if e.complexity.MediaExif.DateShot == nil {
 			break
@@ -1745,7 +1772,7 @@ type Query {
   "Get media owned by the logged in user, returned in GeoJson format"
   myMediaGeoJson: Any! @isAuthorized
   "Get the mapbox api token, returns null if mapbox is not enabled"
-  mapboxToken: String @isAuthorized
+  mapboxToken: String
 
   shareToken(credentials: ShareTokenCredentials!): ShareToken!
   shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
@@ -1955,8 +1982,6 @@ type Album {
   path: [Album!]!
 
   shares: [ShareToken!]!
-
-  #coverID: Int
 }
 
 type MediaURL {
@@ -2029,6 +2054,14 @@ type MediaEXIF {
   flash: Int
   "An index describing the mode for adjusting the exposure of the image"
   exposureProgram: Int
+  coordinates: Coordinates
+}
+
+type Coordinates {
+  "GPS latitude in degrees"
+  latitude: Float!
+  "GPS longitude in degrees"
+  longitude: Float!
 }
 
 type VideoMetadata {
@@ -3459,6 +3492,76 @@ func (ec *executionContext) _AuthorizeResult_token(ctx context.Context, field gr
 	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Coordinates_latitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "Coordinates",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   false,
+		IsResolver: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Latitude, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(float64)
+	fc.Result = res
+	return ec.marshalNFloat2float64(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Coordinates_longitude(ctx context.Context, field graphql.CollectedField, obj *models.Coordinates) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "Coordinates",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   false,
+		IsResolver: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Longitude, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(float64)
+	fc.Result = res
+	return ec.marshalNFloat2float64(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _FaceGroup_id(ctx context.Context, field graphql.CollectedField, obj *models.FaceGroup) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -4853,6 +4956,38 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel
 	return ec.marshalOInt2ᚖint64(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _MediaEXIF_coordinates(ctx context.Context, field graphql.CollectedField, obj *models.MediaEXIF) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "MediaEXIF",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   true,
+		IsResolver: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Coordinates(), nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*models.Coordinates)
+	fc.Result = res
+	return ec.marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -7283,28 +7418,8 @@ func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphq
 
 	ctx = graphql.WithFieldContext(ctx, fc)
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
-		directive0 := func(rctx context.Context) (interface{}, error) {
-			ctx = rctx // use context from middleware stack in children
-			return ec.resolvers.Query().MapboxToken(rctx)
-		}
-		directive1 := func(ctx context.Context) (interface{}, error) {
-			if ec.directives.IsAuthorized == nil {
-				return nil, errors.New("directive isAuthorized is not implemented")
-			}
-			return ec.directives.IsAuthorized(ctx, nil, directive0)
-		}
-
-		tmp, err := directive1(rctx)
-		if err != nil {
-			return nil, graphql.ErrorOnPath(ctx, err)
-		}
-		if tmp == nil {
-			return nil, nil
-		}
-		if data, ok := tmp.(*string); ok {
-			return data, nil
-		}
-		return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp)
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Query().MapboxToken(rctx)
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -10429,6 +10544,38 @@ func (ec *executionContext) _AuthorizeResult(ctx context.Context, sel ast.Select
 	return out
 }
 
+var coordinatesImplementors = []string{"Coordinates"}
+
+func (ec *executionContext) _Coordinates(ctx context.Context, sel ast.SelectionSet, obj *models.Coordinates) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, coordinatesImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	var invalids uint32
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("Coordinates")
+		case "latitude":
+			out.Values[i] = ec._Coordinates_latitude(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		case "longitude":
+			out.Values[i] = ec._Coordinates_longitude(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch()
+	if invalids > 0 {
+		return graphql.Null
+	}
+	return out
+}
+
 var faceGroupImplementors = []string{"FaceGroup"}
 
 func (ec *executionContext) _FaceGroup(ctx context.Context, sel ast.SelectionSet, obj *models.FaceGroup) graphql.Marshaler {
@@ -10824,6 +10971,8 @@ func (ec *executionContext) _MediaEXIF(ctx context.Context, sel ast.SelectionSet
 			out.Values[i] = ec._MediaEXIF_flash(ctx, field, obj)
 		case "exposureProgram":
 			out.Values[i] = ec._MediaEXIF_exposureProgram(ctx, field, obj)
+		case "coordinates":
+			out.Values[i] = ec._MediaEXIF_coordinates(ctx, field, obj)
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -12875,6 +13024,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast
 	return graphql.MarshalBoolean(*v)
 }
 
+func (ec *executionContext) marshalOCoordinates2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐCoordinates(ctx context.Context, sel ast.SelectionSet, v *models.Coordinates) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return ec._Coordinates(ctx, sel, v)
+}
+
 func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v interface{}) (*float64, error) {
 	if v == nil {
 		return nil, nil
diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go
index 84eaf07..e1922df 100644
--- a/api/graphql/models/generated.go
+++ b/api/graphql/models/generated.go
@@ -15,6 +15,13 @@ type AuthorizeResult struct {
 	Token   *string `json:"token"`
 }
 
+type Coordinates struct {
+	// GPS latitude in degrees
+	Latitude float64 `json:"latitude"`
+	// GPS longitude in degrees
+	Longitude float64 `json:"longitude"`
+}
+
 type MediaDownload struct {
 	Title    string    `json:"title"`
 	MediaURL *MediaURL `json:"mediaUrl"`
diff --git a/api/graphql/models/media_exif.go b/api/graphql/models/media_exif.go
index dec9d67..37af1e4 100644
--- a/api/graphql/models/media_exif.go
+++ b/api/graphql/models/media_exif.go
@@ -28,3 +28,14 @@ func (MediaEXIF) TableName() string {
 func (exif *MediaEXIF) Media() *Media {
 	panic("not implemented")
 }
+
+func (exif *MediaEXIF) Coordinates() *Coordinates {
+	if exif.GPSLatitude == nil || exif.GPSLongitude == nil {
+		return nil
+	}
+
+	return &Coordinates{
+		Latitude:  *exif.GPSLatitude,
+		Longitude: *exif.GPSLongitude,
+	}
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 166b435..36a2df7 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -76,7 +76,7 @@ type Query {
   "Get media owned by the logged in user, returned in GeoJson format"
   myMediaGeoJson: Any! @isAuthorized
   "Get the mapbox api token, returns null if mapbox is not enabled"
-  mapboxToken: String @isAuthorized
+  mapboxToken: String
 
   shareToken(credentials: ShareTokenCredentials!): ShareToken!
   shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
@@ -358,6 +358,15 @@ type MediaEXIF {
   flash: Int
   "An index describing the mode for adjusting the exposure of the image"
   exposureProgram: Int
+  "GPS coordinates of where the image was taken"
+  coordinates: Coordinates
+}
+
+type Coordinates {
+  "GPS latitude in degrees"
+  latitude: Float!
+  "GPS longitude in degrees"
+  longitude: Float!
 }
 
 type VideoMetadata {
diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx
index 47d0eb8..4ec4423 100644
--- a/ui/src/Pages/PlacesPage/MapClusterMarker.tsx
+++ b/ui/src/Pages/PlacesPage/MapClusterMarker.tsx
@@ -52,14 +52,6 @@ const MapClusterMarker = ({
   const thumbnail = JSON.parse(marker.thumbnail)
 
   const presentMedia = () => {
-    // presentMarkerClicked({
-    //   dispatchMedia: dispatchMarkerMedia,
-    //   mediaState: markerMediaState,
-    //   marker: {
-    //     cluster: !!marker.cluster,
-    //     id: marker.cluster ? marker.cluster_id : marker.media_id,
-    //   },
-    // })
     dispatchMarkerMedia({
       type: 'replacePresentMarker',
       marker: {
@@ -67,10 +59,6 @@ const MapClusterMarker = ({
         id: marker.cluster ? marker.cluster_id : marker.media_id,
       },
     })
-    // setPresentMarker({
-    //   cluster: !!marker.cluster,
-    //   id: marker.cluster ? marker.cluster_id : marker.media_id,
-    // })
   }
 
   return (
diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx
index 2ffb15e..c4b4be4 100644
--- a/ui/src/Pages/PlacesPage/MapPresentMarker.tsx
+++ b/ui/src/Pages/PlacesPage/MapPresentMarker.tsx
@@ -80,6 +80,9 @@ type MapPresetMarkerProps = {
   dispatchMarkerMedia: React.Dispatch<PlacesAction>
 }
 
+/**
+ * Full-screen present-view that works with PlacesState
+ */
 const MapPresentMarker = ({
   map,
   markerMediaState,
diff --git a/ui/src/Pages/PlacesPage/PlacesPage.tsx b/ui/src/Pages/PlacesPage/PlacesPage.tsx
index 1d15005..b41f432 100644
--- a/ui/src/Pages/PlacesPage/PlacesPage.tsx
+++ b/ui/src/Pages/PlacesPage/PlacesPage.tsx
@@ -1,30 +1,24 @@
 import { gql, useQuery } from '@apollo/client'
 import type mapboxgl from 'mapbox-gl'
-import React, { useEffect, useReducer, useRef, useState } from 'react'
+import React, { useReducer } from 'react'
 import { Helmet } from 'react-helmet'
 import { useTranslation } from 'react-i18next'
 import styled from 'styled-components'
 import Layout from '../../components/layout/Layout'
-import { makeUpdateMarkers } from './mapboxHelperFunctions'
-import MapPresentMarker from './MapPresentMarker'
+import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions'
+import useMapboxMap from '../../components/mapbox/MapboxMap'
 import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer'
-import { placesReducer } from './placesReducer'
-
-import 'mapbox-gl/dist/mapbox-gl.css'
+import MapPresentMarker from './MapPresentMarker'
+import { PlacesAction, placesReducer } from './placesReducer'
+import { mediaGeoJson } from './__generated__/mediaGeoJson'
 
 const MapWrapper = styled.div`
   width: 100%;
   height: calc(100vh - 120px);
 `
 
-const MapContainer = styled.div`
-  width: 100%;
-  height: 100%;
-`
-
 const MAPBOX_DATA_QUERY = gql`
-  query placePageMapboxToken {
-    mapboxToken
+  query mediaGeoJson {
     myMediaGeoJson
   }
 `
@@ -37,10 +31,9 @@ export type PresentMarker = {
 const MapPage = () => {
   const { t } = useTranslation()
 
-  const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl | null>()
-  const mapContainer = useRef<HTMLDivElement | null>(null)
-  const map = useRef<mapboxgl.Map | null>(null)
-  // const [presentMarker, setPresentMarker] = useState<PresentMarker | null>(null)
+  const { data: mapboxData } = useQuery<mediaGeoJson>(MAPBOX_DATA_QUERY, {
+    fetchPolicy: 'cache-first',
+  })
 
   const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, {
     presenting: false,
@@ -48,76 +41,21 @@ const MapPage = () => {
     media: [],
   })
 
-  const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY)
+  const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({
+    configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }),
+  })
 
-  useEffect(() => {
-    async function loadMapboxLibrary() {
-      const mapbox = (await import('mapbox-gl')).default
-
-      setMapboxLibrary(mapbox)
-    }
-    loadMapboxLibrary()
-  }, [])
-
-  useEffect(() => {
-    if (
-      mapboxLibrary == null ||
-      mapContainer.current == null ||
-      mapboxData == null ||
-      map.current != null
-    ) {
-      return
-    }
-
-    mapboxLibrary.accessToken = mapboxData.mapboxToken
-
-    map.current = new mapboxLibrary.Map({
-      container: mapContainer.current,
-      style: 'mapbox://styles/mapbox/streets-v11',
-      zoom: 1,
-    })
-
-    // Add map navigation control
-    map.current.addControl(new mapboxLibrary.NavigationControl())
-
-    map.current.on('load', () => {
-      if (map.current == null) {
-        console.error('ERROR: map is null')
-        return
-      }
-
-      map.current.addSource('media', {
-        type: 'geojson',
-        data: mapboxData.myMediaGeoJson,
-        cluster: true,
-        clusterRadius: 50,
-        clusterProperties: {
-          thumbnail: ['coalesce', ['get', 'thumbnail'], false],
-        },
+  urlPresentModeSetupHook({
+    dispatchMedia: dispatchMarkerMedia,
+    openPresentMode: event => {
+      dispatchMarkerMedia({
+        type: 'openPresentMode',
+        activeIndex: event.state.activeIndex,
       })
+    },
+  })
 
-      // Add dummy layer for features to be queryable
-      map.current.addLayer({
-        id: 'media-points',
-        type: 'circle',
-        source: 'media',
-        filter: ['!', true],
-      })
-
-      const updateMarkers = makeUpdateMarkers({
-        map: map.current,
-        mapboxLibrary,
-        dispatchMarkerMedia,
-      })
-
-      map.current.on('move', updateMarkers)
-      map.current.on('moveend', updateMarkers)
-      map.current.on('sourcedata', updateMarkers)
-      updateMarkers()
-    })
-  }, [mapContainer, mapboxLibrary, mapboxData])
-
-  if (mapboxData && mapboxData.mapboxToken == null) {
+  if (mapboxData && mapboxToken == null) {
     return (
       <Layout title={t('places_page.title', 'Places')}>
         <h1>Mapbox token is not set</h1>
@@ -134,34 +72,64 @@ const MapPage = () => {
     )
   }
 
-  urlPresentModeSetupHook({
-    dispatchMedia: dispatchMarkerMedia,
-    openPresentMode: event => {
-      dispatchMarkerMedia({
-        type: 'openPresentMode',
-        activeIndex: event.state.activeIndex,
-      })
-    },
-  })
-
   return (
     <Layout title="Places">
       <Helmet>
         {/* <link rel="stylesheet" href="/mapbox-gl.css" /> */}
         {/* <style type="text/css">{mapboxStyles}</style> */}
       </Helmet>
-      <MapWrapper>
-        <MapContainer ref={mapContainer}></MapContainer>
-      </MapWrapper>
+      <MapWrapper>{mapContainer}</MapWrapper>
       <MapPresentMarker
-        map={map.current}
+        map={mapboxMap}
         markerMediaState={markerMediaState}
         dispatchMarkerMedia={dispatchMarkerMedia}
-        // presentMarker={presentMarker}
-        // setPresentMarker={setPresentMarker}
       />
     </Layout>
   )
 }
 
+const configureMapbox =
+  ({
+    mapboxData,
+    dispatchMarkerMedia,
+  }: {
+    mapboxData?: mediaGeoJson
+    dispatchMarkerMedia: React.Dispatch<PlacesAction>
+  }) =>
+  (map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl) => {
+    // Add map navigation control
+    map.addControl(new mapboxLibrary.NavigationControl())
+
+    map.on('load', () => {
+      if (map == null) {
+        console.error('ERROR: map is null')
+        return
+      }
+
+      map.addSource('media', {
+        type: 'geojson',
+        data: mapboxData?.myMediaGeoJson,
+        cluster: true,
+        clusterRadius: 50,
+        clusterProperties: {
+          thumbnail: ['coalesce', ['get', 'thumbnail'], false],
+        },
+      })
+
+      // Add dummy layer for features to be queryable
+      map.addLayer({
+        id: 'media-points',
+        type: 'circle',
+        source: 'media',
+        filter: ['!', true],
+      })
+
+      registerMediaMarkers({
+        map: map,
+        mapboxLibrary,
+        dispatchMarkerMedia,
+      })
+    })
+  }
+
 export default MapPage
diff --git a/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts
new file mode 100644
index 0000000..5f6d5da
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/__generated__/mediaGeoJson.ts
@@ -0,0 +1,15 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: mediaGeoJson
+// ====================================================
+
+export interface mediaGeoJson {
+  /**
+   * Get media owned by the logged in user, returned in GeoJson format
+   */
+  myMediaGeoJson: any
+}
diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx
deleted file mode 100644
index dad9147..0000000
--- a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import type mapboxgl from 'mapbox-gl'
-import type geojson from 'geojson'
-import React from 'react'
-import ReactDOM from 'react-dom'
-import MapClusterMarker from './MapClusterMarker'
-import { MediaMarker } from './MapPresentMarker'
-import { PlacesAction } from './placesReducer'
-
-const markers: { [key: string]: mapboxgl.Marker } = {}
-let markersOnScreen: typeof markers = {}
-
-type makeUpdateMarkersArgs = {
-  map: mapboxgl.Map
-  mapboxLibrary: typeof mapboxgl
-  dispatchMarkerMedia: React.Dispatch<PlacesAction>
-  // setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
-}
-
-export const makeUpdateMarkers = ({
-  map,
-  mapboxLibrary,
-  dispatchMarkerMedia,
-}: makeUpdateMarkersArgs) => () => {
-  const newMarkers: typeof markers = {}
-  const features = map.querySourceFeatures('media')
-
-  // for every media on the screen, create an HTML marker for it (if we didn't yet),
-  // and add it to the map if it's not there already
-  for (const feature of features) {
-    const point = feature.geometry as geojson.Point
-    const coords = point.coordinates as [number, number]
-    const props = feature.properties as MediaMarker
-    if (props == null) {
-      console.warn('WARN: geojson feature had no properties', feature)
-      continue
-    }
-
-    const id = props.cluster
-      ? `cluster_${props.cluster_id}`
-      : `media_${props.media_id}`
-
-    let marker = markers[id]
-    if (!marker) {
-      const el = createClusterPopupElement(props, {
-        dispatchMarkerMedia,
-      })
-      marker = markers[id] = new mapboxLibrary.Marker({
-        element: el,
-      }).setLngLat(coords)
-    }
-    newMarkers[id] = marker
-
-    if (!markersOnScreen[id]) marker.addTo(map)
-  }
-  // for every marker we've added previously, remove those that are no longer visible
-  for (const id in markersOnScreen) {
-    if (!newMarkers[id]) markersOnScreen[id].remove()
-  }
-  markersOnScreen = newMarkers
-}
-
-function createClusterPopupElement(
-  geojsonProps: MediaMarker,
-  {
-    dispatchMarkerMedia,
-  }: {
-    dispatchMarkerMedia: React.Dispatch<PlacesAction>
-  }
-) {
-  // setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
-  const el = document.createElement('div')
-  ReactDOM.render(
-    <MapClusterMarker
-      marker={geojsonProps}
-      dispatchMarkerMedia={dispatchMarkerMedia}
-    />,
-    el
-  )
-  return el
-}
diff --git a/ui/src/components/mapbox/MapboxMap.tsx b/ui/src/components/mapbox/MapboxMap.tsx
new file mode 100644
index 0000000..d996e1c
--- /dev/null
+++ b/ui/src/components/mapbox/MapboxMap.tsx
@@ -0,0 +1,74 @@
+import React, { useState, useRef, useEffect } from 'react'
+import { gql, useQuery } from '@apollo/client'
+import type mapboxgl from 'mapbox-gl'
+import styled from 'styled-components'
+
+import 'mapbox-gl/dist/mapbox-gl.css'
+import { mapboxToken } from './__generated__/mapboxToken'
+
+const MAPBOX_TOKEN_QUERY = gql`
+  query mapboxToken {
+    mapboxToken
+    myMediaGeoJson
+  }
+`
+
+const MapContainer = styled.div`
+  width: 100%;
+  height: 100%;
+`
+
+type MapboxMapProps = {
+  configureMapbox(map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl): void
+  readonly initialZoom?: number
+}
+
+const useMapboxMap = ({ configureMapbox, initialZoom = 1 }: MapboxMapProps) => {
+  const [mapboxLibrary, setMapboxLibrary] = useState<typeof mapboxgl>()
+  const mapContainer = useRef<HTMLDivElement | null>(null)
+  const map = useRef<mapboxgl.Map | null>(null)
+
+  const { data: mapboxData } = useQuery<mapboxToken>(MAPBOX_TOKEN_QUERY, {
+    fetchPolicy: 'cache-first',
+  })
+
+  useEffect(() => {
+    async function loadMapboxLibrary() {
+      const mapbox = (await import('mapbox-gl')).default
+
+      setMapboxLibrary(mapbox)
+    }
+    loadMapboxLibrary()
+  }, [])
+
+  useEffect(() => {
+    if (
+      mapboxLibrary == null ||
+      mapContainer.current == null ||
+      mapboxData == null ||
+      map.current != null
+    ) {
+      return
+    }
+
+    if (mapboxData.mapboxToken)
+      mapboxLibrary.accessToken = mapboxData.mapboxToken
+
+    map.current = new mapboxLibrary.Map({
+      container: mapContainer.current,
+      style: 'mapbox://styles/mapbox/streets-v11',
+      zoom: initialZoom,
+    })
+
+    configureMapbox(map.current, mapboxLibrary)
+  }, [mapContainer, mapboxLibrary, mapboxData])
+
+  return {
+    mapContainer: <MapContainer ref={mapContainer}></MapContainer>,
+    mapboxMap: map.current,
+    mapboxLibrary,
+    mapboxToken: mapboxData?.mapboxToken || null,
+  }
+}
+
+export default useMapboxMap
diff --git a/ui/src/components/mapbox/__generated__/mapboxToken.ts b/ui/src/components/mapbox/__generated__/mapboxToken.ts
new file mode 100644
index 0000000..644bbae
--- /dev/null
+++ b/ui/src/components/mapbox/__generated__/mapboxToken.ts
@@ -0,0 +1,19 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: mapboxToken
+// ====================================================
+
+export interface mapboxToken {
+  /**
+   * Get the mapbox api token, returns null if mapbox is not enabled
+   */
+  mapboxToken: string | null
+  /**
+   * Get media owned by the logged in user, returned in GeoJson format
+   */
+  myMediaGeoJson: any
+}
diff --git a/ui/src/components/mapbox/mapboxHelperFunctions.tsx b/ui/src/components/mapbox/mapboxHelperFunctions.tsx
new file mode 100644
index 0000000..6ea3a20
--- /dev/null
+++ b/ui/src/components/mapbox/mapboxHelperFunctions.tsx
@@ -0,0 +1,93 @@
+import type mapboxgl from 'mapbox-gl'
+import type geojson from 'geojson'
+import React from 'react'
+import ReactDOM from 'react-dom'
+import MapClusterMarker from '../../Pages/PlacesPage/MapClusterMarker'
+import { MediaMarker } from '../../Pages/PlacesPage/MapPresentMarker'
+import { PlacesAction } from '../../Pages/PlacesPage/placesReducer'
+
+const markers: { [key: string]: mapboxgl.Marker } = {}
+let markersOnScreen: typeof markers = {}
+
+type registerMediaMarkersArgs = {
+  map: mapboxgl.Map
+  mapboxLibrary: typeof mapboxgl
+  dispatchMarkerMedia: React.Dispatch<PlacesAction>
+}
+
+/**
+ * Add appropriate event handlers to the map, to render and update media markers
+ * Expects the provided mapbox map to contain geojson source of media
+ */
+export const registerMediaMarkers = (args: registerMediaMarkersArgs) => {
+  const updateMarkers = makeUpdateMarkers(args)
+
+  args.map.on('move', updateMarkers)
+  args.map.on('moveend', updateMarkers)
+  args.map.on('sourcedata', updateMarkers)
+  updateMarkers()
+}
+
+/**
+ * Make a function that can be passed to Mapbox to tell it how to render and update the image markers
+ */
+const makeUpdateMarkers =
+  ({ map, mapboxLibrary, dispatchMarkerMedia }: registerMediaMarkersArgs) =>
+  () => {
+    const newMarkers: typeof markers = {}
+    const features = map.querySourceFeatures('media')
+
+    // for every media on the screen, create an HTML marker for it (if we didn't yet),
+    // and add it to the map if it's not there already
+    for (const feature of features) {
+      const point = feature.geometry as geojson.Point
+      const coords = point.coordinates as [number, number]
+      const props = feature.properties as MediaMarker
+      if (props == null) {
+        console.warn('WARN: geojson feature had no properties', feature)
+        continue
+      }
+
+      const id = props.cluster
+        ? `cluster_${props.cluster_id}`
+        : `media_${props.media_id}`
+
+      let marker = markers[id]
+      if (!marker) {
+        const el = createClusterPopupElement(props, {
+          dispatchMarkerMedia,
+        })
+        marker = markers[id] = new mapboxLibrary.Marker({
+          element: el,
+        }).setLngLat(coords)
+      }
+      newMarkers[id] = marker
+
+      if (!markersOnScreen[id]) marker.addTo(map)
+    }
+    // for every marker we've added previously, remove those that are no longer visible
+    for (const id in markersOnScreen) {
+      if (!newMarkers[id]) markersOnScreen[id].remove()
+    }
+    markersOnScreen = newMarkers
+  }
+
+function createClusterPopupElement(
+  geojsonProps: MediaMarker,
+  {
+    dispatchMarkerMedia,
+  }: {
+    dispatchMarkerMedia: React.Dispatch<PlacesAction>
+  }
+) {
+  // setPresentMarker: React.Dispatch<React.SetStateAction<PresentMarker | null>>
+  const el = document.createElement('div')
+  ReactDOM.render(
+    <MapClusterMarker
+      marker={geojsonProps}
+      dispatchMarkerMedia={dispatchMarkerMedia}
+    />,
+    el
+  )
+  return el
+}
