diff --git a/api/example.env b/api/example.env
index 86b0e78..46d4f96 100644
--- a/api/example.env
+++ b/api/example.env
@@ -14,6 +14,10 @@ SERVE_UI=0
 # When SERVE_UI is 1, PUBLIC_ENDPOINT is used instead of API_ENDPOINT and UI_ENDPOINT
 #PUBLIC_ENDPOINT=http://localhost:4001/
 
+# Enter a valid mapbox token, to enable maps feature
+# A token can be created for free at https://mapbox.com
+#MAPBOX_TOKEN=<insert mapbox token here>
+
 # Set to 1 to set server in development mode, this enables graphql playground
 # Remove this if running in production
 DEVELOPMENT=1
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index b5c010f..1b3171e 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -144,9 +144,12 @@ type ComplexityRoot struct {
 
 	Query struct {
 		Album                      func(childComplexity int, id int) int
+		MapboxToken                func(childComplexity int) int
 		Media                      func(childComplexity int, id int) int
+		MediaList                  func(childComplexity int, ids []int) int
 		MyAlbums                   func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) int
 		MyMedia                    func(childComplexity int, filter *models.Filter) int
+		MyMediaGeoJSON             func(childComplexity int) int
 		MyUser                     func(childComplexity int) int
 		Search                     func(childComplexity int, query string, limitMedia *int, limitAlbums *int) int
 		ShareToken                 func(childComplexity int, token string, password *string) int
@@ -255,6 +258,9 @@ type QueryResolver interface {
 	Album(ctx context.Context, id int) (*models.Album, error)
 	MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error)
 	Media(ctx context.Context, id int) (*models.Media, error)
+	MediaList(ctx context.Context, ids []int) ([]*models.Media, error)
+	MyMediaGeoJSON(ctx context.Context) (interface{}, error)
+	MapboxToken(ctx context.Context) (*string, error)
 	ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
 	ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error)
 	Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error)
@@ -846,6 +852,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Query.Album(childComplexity, args["id"].(int)), true
 
+	case "Query.mapboxToken":
+		if e.complexity.Query.MapboxToken == nil {
+			break
+		}
+
+		return e.complexity.Query.MapboxToken(childComplexity), true
+
 	case "Query.media":
 		if e.complexity.Query.Media == nil {
 			break
@@ -858,6 +871,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Query.Media(childComplexity, args["id"].(int)), true
 
+	case "Query.mediaList":
+		if e.complexity.Query.MediaList == nil {
+			break
+		}
+
+		args, err := ec.field_Query_mediaList_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Query.MediaList(childComplexity, args["ids"].([]int)), true
+
 	case "Query.myAlbums":
 		if e.complexity.Query.MyAlbums == nil {
 			break
@@ -882,6 +907,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Query.MyMedia(childComplexity, args["filter"].(*models.Filter)), true
 
+	case "Query.myMediaGeoJson":
+		if e.complexity.Query.MyMediaGeoJSON == nil {
+			break
+		}
+
+		return e.complexity.Query.MyMediaGeoJSON(childComplexity), true
+
 	case "Query.myUser":
 		if e.complexity.Query.MyUser == nil {
 			break
@@ -1252,6 +1284,7 @@ var sources = []*ast.Source{
 	{Name: "graphql/schema.graphql", Input: `directive @isAdmin on FIELD_DEFINITION
 
 scalar Time
+scalar Any
 
 enum OrderDirection {
   ASC
@@ -1291,6 +1324,14 @@ type Query {
   "Get media by id, user must own the media or be admin"
   media(id: Int!): Media!
 
+  "Get a list of media by their ids, user must own the media or be admin"
+  mediaList(ids: [Int!]!): [Media!]!
+
+  "Get media owned by the logged in user, returned in GeoJson format"
+  myMediaGeoJson: Any!
+  "Get the mapbox api token, returns null if mapbox is not enabled"
+  mapboxToken: String
+
   shareToken(token: String!, password: String): ShareToken!
   shareTokenValidatePassword(token: String!, password: String): Boolean!
 
@@ -1560,6 +1601,7 @@ func (ec *executionContext) field_Album_media_args(ctx context.Context, rawArgs
 	args["filter"] = arg0
 	var arg1 *bool
 	if tmp, ok := rawArgs["onlyFavorites"]; ok {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyFavorites"))
 		arg1, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp)
 		if err != nil {
 			return nil, err
@@ -1986,6 +2028,21 @@ func (ec *executionContext) field_Query_album_args(ctx context.Context, rawArgs
 	return args, nil
 }
 
+func (ec *executionContext) field_Query_mediaList_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 []int
+	if tmp, ok := rawArgs["ids"]; ok {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ids"))
+		arg0, err = ec.unmarshalNInt2ᚕintᚄ(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["ids"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Query_media_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -2033,6 +2090,7 @@ func (ec *executionContext) field_Query_myAlbums_args(ctx context.Context, rawAr
 	args["showEmpty"] = arg2
 	var arg3 *bool
 	if tmp, ok := rawArgs["onlyWithFavorites"]; ok {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyWithFavorites"))
 		arg3, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp)
 		if err != nil {
 			return nil, err
@@ -4941,6 +4999,115 @@ func (ec *executionContext) _Query_media(ctx context.Context, field graphql.Coll
 	return ec.marshalNMedia2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Query_mediaList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "Query",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   true,
+		IsResolver: true,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	rawArgs := field.ArgumentMap(ec.Variables)
+	args, err := ec.field_Query_mediaList_args(ctx, rawArgs)
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	fc.Args = args
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Query().MediaList(rctx, args["ids"].([]int))
+	})
+	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.([]*models.Media)
+	fc.Result = res
+	return ec.marshalNMedia2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Query_myMediaGeoJson(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "Query",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   true,
+		IsResolver: true,
+	}
+
+	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 ec.resolvers.Query().MyMediaGeoJSON(rctx)
+	})
+	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.(interface{})
+	fc.Result = res
+	return ec.marshalNAny2interface(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:     "Query",
+		Field:      field,
+		Args:       nil,
+		IsMethod:   true,
+		IsResolver: true,
+	}
+
+	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 ec.resolvers.Query().MapboxToken(rctx)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*string)
+	fc.Result = res
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -8113,6 +8280,45 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 				}
 				return res
 			})
+		case "mediaList":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_mediaList(ctx, field)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		case "myMediaGeoJson":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_myMediaGeoJson(ctx, field)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
+		case "mapboxToken":
+			field := field
+			out.Concurrently(i, func() (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_mapboxToken(ctx, field)
+				return res
+			})
 		case "shareToken":
 			field := field
 			out.Concurrently(i, func() (res graphql.Marshaler) {
@@ -8779,6 +8985,27 @@ func (ec *executionContext) marshalNAlbum2ᚖgithubᚗcomᚋviktorstrateᚋphoto
 	return ec._Album(ctx, sel, v)
 }
 
+func (ec *executionContext) unmarshalNAny2interface(ctx context.Context, v interface{}) (interface{}, error) {
+	res, err := graphql.UnmarshalAny(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNAny2interface(ctx context.Context, sel ast.SelectionSet, v interface{}) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := graphql.MarshalAny(v)
+	if res == graphql.Null {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+	}
+	return res
+}
+
 func (ec *executionContext) marshalNAuthorizeResult2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAuthorizeResult(ctx context.Context, sel ast.SelectionSet, v models.AuthorizeResult) graphql.Marshaler {
 	return ec._AuthorizeResult(ctx, sel, &v)
 }
@@ -8838,6 +9065,36 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti
 	return res
 }
 
+func (ec *executionContext) unmarshalNInt2ᚕintᚄ(ctx context.Context, v interface{}) ([]int, error) {
+	var vSlice []interface{}
+	if v != nil {
+		if tmp1, ok := v.([]interface{}); ok {
+			vSlice = tmp1
+		} else {
+			vSlice = []interface{}{v}
+		}
+	}
+	var err error
+	res := make([]int, len(vSlice))
+	for i := range vSlice {
+		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i))
+		res[i], err = ec.unmarshalNInt2int(ctx, vSlice[i])
+		if err != nil {
+			return nil, err
+		}
+	}
+	return res, nil
+}
+
+func (ec *executionContext) marshalNInt2ᚕintᚄ(ctx context.Context, sel ast.SelectionSet, v []int) graphql.Marshaler {
+	ret := make(graphql.Array, len(v))
+	for i := range v {
+		ret[i] = ec.marshalNInt2int(ctx, sel, v[i])
+	}
+
+	return ret
+}
+
 func (ec *executionContext) marshalNMedia2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx context.Context, sel ast.SelectionSet, v models.Media) graphql.Marshaler {
 	return ec._Media(ctx, sel, &v)
 }
diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go
index 7d7f533..07890cc 100644
--- a/api/graphql/resolvers/media.go
+++ b/api/graphql/resolvers/media.go
@@ -60,6 +60,44 @@ func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error
 	return media, nil
 }
 
+func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Media, error) {
+	user := auth.UserFromContext(ctx)
+	if user == nil {
+		return nil, auth.ErrUnauthorized
+	}
+
+	if len(ids) == 0 {
+		return nil, errors.New("no ids provided")
+	}
+
+	mediaIDQuestions := strings.Repeat("?,", len(ids))[:len(ids)*2-1]
+
+	queryArgs := make([]interface{}, 0)
+	for _, id := range ids {
+		queryArgs = append(queryArgs, id)
+	}
+	queryArgs = append(queryArgs, user.UserID)
+
+	rows, err := r.Database.Query(`
+		SELECT media.* FROM media
+		JOIN album ON media.album_id = album.album_id
+		WHERE media.media_id IN (`+mediaIDQuestions+`) AND album.owner_id = ?
+		AND media.media_id IN (
+			SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
+		)
+	`, queryArgs...)
+	if err != nil {
+		return nil, errors.Wrap(err, "could not get media list by media_id and user_id from database")
+	}
+
+	media, err := models.NewMediaFromRows(rows)
+	if err != nil {
+		return nil, errors.Wrap(err, "could not convert database rows to media")
+	}
+
+	return media, nil
+}
+
 type mediaResolver struct {
 	*Resolver
 }
diff --git a/api/graphql/resolvers/mediaGeoJson.go b/api/graphql/resolvers/mediaGeoJson.go
new file mode 100644
index 0000000..ea4de3e
--- /dev/null
+++ b/api/graphql/resolvers/mediaGeoJson.go
@@ -0,0 +1,137 @@
+package resolvers
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path"
+
+	"github.com/viktorstrate/photoview/api/graphql/auth"
+	"github.com/viktorstrate/photoview/api/utils"
+)
+
+type geoJSONFeatureCollection struct {
+	Type     string           `json:"type"`
+	Features []geoJSONFeature `json:"features"`
+}
+
+type geoJSONFeature struct {
+	Type       string                 `json:"type"`
+	Properties interface{}            `json:"properties"`
+	Geometry   geoJSONFeatureGeometry `json:"geometry"`
+}
+
+type geoJSONMediaProperties struct {
+	MediaID    int    `json:"media_id"`
+	MediaTitle string `json:"media_title"`
+	Thumbnail  struct {
+		URL    string `json:"url"`
+		Width  int    `json:"width"`
+		Height int    `json:"height"`
+	} `json:"thumbnail"`
+}
+
+type geoJSONFeatureGeometry struct {
+	Type        string     `json:"type"`
+	Coordinates [2]float64 `json:"coordinates"`
+}
+
+func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
+	return geoJSONFeatureCollection{
+		Type:     "FeatureCollection",
+		Features: features,
+	}
+}
+
+func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
+	return geoJSONFeature{
+		Type:       "Feature",
+		Properties: properties,
+		Geometry:   geometry,
+	}
+}
+
+func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
+	coordinates := [2]float64{long, lat}
+
+	return geoJSONFeatureGeometry{
+		Type:        "Point",
+		Coordinates: coordinates,
+	}
+}
+
+func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (interface{}, error) {
+
+	user := auth.UserFromContext(ctx)
+	if user == nil {
+		return nil, errors.New("unauthorized")
+	}
+
+	rows, err := r.Database.Query(`
+	SELECT media.media_id, media.title,
+	  url.media_name AS thumbnail_name, url.width AS thumbnail_width, url.height AS thumbnail_height,
+	  exif.gps_latitude, exif.gps_longitude FROM media_exif exif
+	INNER JOIN media ON exif.exif_id = media.exif_id
+	INNER JOIN media_url url ON media.media_id = url.media_id
+	INNER JOIN album ON media.album_id = album.album_id
+	WHERE exif.gps_latitude IS NOT NULL
+	  AND exif.gps_longitude IS NOT NULL
+		AND url.purpose = 'thumbnail'
+		AND album.owner_id = ?;
+	`, user.UserID)
+	defer rows.Close()
+	if err != nil {
+		return nil, err
+	}
+
+	features := make([]geoJSONFeature, 0)
+
+	for rows.Next() {
+
+		var mediaID int
+		var mediaTitle string
+		var thumbnailName string
+		var thumbnailWidth int
+		var thumbnailHeight int
+		var latitude float64
+		var longitude float64
+
+		if err := rows.Scan(&mediaID, &mediaTitle, &thumbnailName, &thumbnailWidth, &thumbnailHeight, &latitude, &longitude); err != nil {
+			return nil, err
+		}
+
+		geoPoint := makeGeoJSONFeatureGeometryPoint(latitude, longitude)
+
+		thumbnailURL := utils.ApiEndpointUrl()
+		thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", thumbnailName)
+
+		properties := geoJSONMediaProperties{
+			MediaID:    mediaID,
+			MediaTitle: mediaTitle,
+			Thumbnail: struct {
+				URL    string `json:"url"`
+				Width  int    `json:"width"`
+				Height int    `json:"height"`
+			}{
+				URL:    thumbnailURL.String(),
+				Width:  thumbnailWidth,
+				Height: thumbnailHeight,
+			},
+		}
+
+		features = append(features, makeGeoJSONFeature(properties, geoPoint))
+	}
+
+	featureCollection := makeGeoJSONFeatureCollection(features)
+
+	return featureCollection, nil
+}
+
+func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
+	mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
+	if mapboxTokenEnv == "" {
+		return nil, nil
+	}
+
+	return &mapboxTokenEnv, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 4ce3c8d..05544ba 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -1,6 +1,7 @@
 directive @isAdmin on FIELD_DEFINITION
 
 scalar Time
+scalar Any
 
 enum OrderDirection {
   ASC
@@ -40,6 +41,14 @@ type Query {
   "Get media by id, user must own the media or be admin"
   media(id: Int!): Media!
 
+  "Get a list of media by their ids, user must own the media or be admin"
+  mediaList(ids: [Int!]!): [Media!]!
+
+  "Get media owned by the logged in user, returned in GeoJson format"
+  myMediaGeoJson: Any!
+  "Get the mapbox api token, returns null if mapbox is not enabled"
+  mapboxToken: String
+
   shareToken(token: String!, password: String): ShareToken!
   shareTokenValidatePassword(token: String!, password: String): Boolean!
 
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index 51d5443..e18046e 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -31,6 +31,11 @@ services:
       # change this value to http://example.com/
       - PUBLIC_ENDPOINT=http://localhost:8000/
 
+      # Optional: To enable map related features, you need to create a mapbox token.
+      # A token can be generated for free here https://account.mapbox.com/access-tokens/
+      # It's a good idea to limit the scope of the token to your own domain, to prevent others from using it.
+      # - MAPBOX_TOKEN=<YOUR TOKEN HERE>
+
     volumes:
       - api_cache:/app/cache
 
diff --git a/.prettierrc b/ui/.prettierrc
similarity index 100%
rename from .prettierrc
rename to ui/.prettierrc
diff --git a/ui/package.json b/ui/package.json
index 1897e40..ec0c4ee 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -47,6 +47,7 @@
     "eslint-plugin-react-hooks": "^4.1.2",
     "husky": "^4.3.0",
     "lint-staged": "^10.4.0",
+    "mapbox-gl": "^1.12.0",
     "parcel-plugin-sw-cache": "^0.3.1",
     "prettier": "^2.1.2",
     "react-router-prop-types": "^1.0.5"
diff --git a/ui/src/Layout.js b/ui/src/Layout.js
index f33f17a..bff6587 100644
--- a/ui/src/Layout.js
+++ b/ui/src/Layout.js
@@ -4,13 +4,13 @@ import styled from 'styled-components'
 import { NavLink } from 'react-router-dom'
 import { Icon } from 'semantic-ui-react'
 import Sidebar from './components/sidebar/Sidebar'
-import { Query } from 'react-apollo'
+import { useQuery } from 'react-apollo'
 import gql from 'graphql-tag'
 import { Authorized } from './AuthorizedRoute'
 import { Helmet } from 'react-helmet'
 import Header from './components/header/Header'
 
-const adminQuery = gql`
+const ADMIN_QUERY = gql`
   query adminQuery {
     myUser {
       admin
@@ -18,6 +18,12 @@ const adminQuery = gql`
   }
 `
 
+const MAPBOX_QUERY = gql`
+  query mapboxEnabledQuery {
+    mapboxToken
+  }
+`
+
 const Container = styled.div`
   height: 100%;
   display: flex;
@@ -88,50 +94,58 @@ const SideButtonLabel = styled.div`
   font-size: 16px;
 `
 
-const Layout = ({ children, title }) => (
-  <Container>
-    <Helmet>
-      <title>{title ? `${title} - Photoview` : `Photoview`}</title>
-    </Helmet>
-    <Authorized>
-      <SideMenu>
-        <SideButton to="/photos" exact>
-          <Icon name="image outline" />
-          <SideButtonLabel>Photos</SideButtonLabel>
-        </SideButton>
-        <SideButton to="/albums" exact>
-          <Icon name="images outline" />
-          <SideButtonLabel>Albums</SideButtonLabel>
-        </SideButton>
-        <Query query={adminQuery}>
-          {({ loading, error, data }) => {
-            if (data && data.myUser && data.myUser.admin) {
-              return (
-                <SideButton to="/settings" exact>
-                  <Icon name="settings" />
-                  <SideButtonLabel>Settings</SideButtonLabel>
-                </SideButton>
-              )
-            }
+const Layout = ({ children, title }) => {
+  const adminQuery = useQuery(ADMIN_QUERY)
+  const mapboxQuery = useQuery(MAPBOX_QUERY)
 
-            return null
-          }}
-        </Query>
-        <SideButton to="/logout">
-          <Icon name="lock" />
-          <SideButtonLabel>Log out</SideButtonLabel>
-        </SideButton>
-      </SideMenu>
-    </Authorized>
-    <Sidebar>
-      <Content id="layout-content">
-        {children}
-        <div style={{ height: 24 }}></div>
-      </Content>
-    </Sidebar>
-    <Header />
-  </Container>
-)
+  const isAdmin =
+    adminQuery.data && adminQuery.data.myUser && adminQuery.data.myUser.admin
+
+  const mapboxEnabled = mapboxQuery.data && mapboxQuery.data.mapboxToken != null
+
+  return (
+    <Container>
+      <Helmet>
+        <title>{title ? `${title} - Photoview` : `Photoview`}</title>
+      </Helmet>
+      <Authorized>
+        <SideMenu>
+          <SideButton to="/photos" exact>
+            <Icon name="image outline" />
+            <SideButtonLabel>Photos</SideButtonLabel>
+          </SideButton>
+          <SideButton to="/albums" exact>
+            <Icon name="images outline" />
+            <SideButtonLabel>Albums</SideButtonLabel>
+          </SideButton>
+          {mapboxEnabled ? (
+            <SideButton to="/places" exact>
+              <Icon name="map outline" />
+              <SideButtonLabel>Places</SideButtonLabel>
+            </SideButton>
+          ) : null}
+          {isAdmin ? (
+            <SideButton to="/settings" exact>
+              <Icon name="settings" />
+              <SideButtonLabel>Settings</SideButtonLabel>
+            </SideButton>
+          ) : null}
+          <SideButton to="/logout">
+            <Icon name="lock" />
+            <SideButtonLabel>Log out</SideButtonLabel>
+          </SideButton>
+        </SideMenu>
+      </Authorized>
+      <Sidebar>
+        <Content id="layout-content">
+          {children}
+          <div style={{ height: 24 }}></div>
+        </Content>
+      </Sidebar>
+      <Header />
+    </Container>
+  )
+}
 
 Layout.propTypes = {
   children: PropTypes.any.isRequired,
diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.js b/ui/src/Pages/PlacesPage/MapClusterMarker.js
new file mode 100644
index 0000000..52e10bc
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/MapClusterMarker.js
@@ -0,0 +1,80 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import styled from 'styled-components'
+
+import imagePopupSrc from './image-popup.svg'
+
+const Wrapper = styled.div`
+  width: 56px;
+  height: 68px;
+  position: relative;
+  margin-top: -54px;
+  cursor: pointer;
+`
+
+const ThumbnailImage = styled.img`
+  position: absolute;
+  width: 48px;
+  height: 48px;
+  top: 4px;
+  left: 4px;
+  border-radius: 2px;
+  object-fit: cover;
+`
+
+const PopupImage = styled.img`
+  width: 100%;
+  height: 100%;
+`
+
+const PointCountCircle = styled.div`
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  width: 24px;
+  height: 24px;
+  background-color: #00b3dc;
+  border-radius: 50%;
+  color: white;
+  text-align: center;
+  padding-top: 2px;
+`
+
+const MapClusterMarker = ({
+  thumbnail: thumbJson,
+  point_count_abbreviated,
+  cluster,
+  cluster_id,
+  media_id,
+  setPresentMarker,
+}) => {
+  const thumbnail = JSON.parse(thumbJson)
+
+  const presentMedia = () => {
+    setPresentMarker({
+      cluster: !!cluster,
+      id: cluster ? cluster_id : media_id,
+    })
+  }
+
+  return (
+    <Wrapper onClick={presentMedia}>
+      <PopupImage src={imagePopupSrc} />
+      <ThumbnailImage src={thumbnail.url} />
+      {cluster && (
+        <PointCountCircle>{point_count_abbreviated}</PointCountCircle>
+      )}
+    </Wrapper>
+  )
+}
+
+MapClusterMarker.propTypes = {
+  thumbnail: PropTypes.string,
+  cluster: PropTypes.bool,
+  point_count_abbreviated: PropTypes.number,
+  cluster_id: PropTypes.number,
+  media_id: PropTypes.number,
+  setPresentMarker: PropTypes.func,
+}
+
+export default MapClusterMarker
diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.js b/ui/src/Pages/PlacesPage/MapPresentMarker.js
new file mode 100644
index 0000000..0f3e9b4
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/MapPresentMarker.js
@@ -0,0 +1,114 @@
+import React, { useEffect, useState, useRef } from 'react'
+import PropTypes from 'prop-types'
+import gql from 'graphql-tag'
+import { useLazyQuery } from 'react-apollo'
+import PresentView from '../../components/photoGallery/presentView/PresentView'
+
+const QUERY_MEDIA = gql`
+  query placePageQueryMedia($mediaIDs: [Int!]!) {
+    mediaList(ids: $mediaIDs) {
+      id
+      title
+      thumbnail {
+        url
+        width
+        height
+      }
+      highRes {
+        url
+        width
+        height
+      }
+      videoWeb {
+        url
+        width
+        height
+      }
+      type
+    }
+  }
+`
+
+const getMediaFromMarker = (map, presentMarker) =>
+  new Promise((resolve, reject) => {
+    const { cluster, id } = presentMarker
+
+    if (cluster) {
+      map
+        .getSource('media')
+        .getClusterLeaves(id, 1000, 0, (error, features) => {
+          if (error) {
+            reject(error)
+            return
+          }
+
+          const media = features.map(feat => feat.properties)
+          resolve(media)
+        })
+    } else {
+      const features = map.querySourceFeatures('media')
+      const media = features.find(f => f.properties.media_id == id).properties
+      resolve([media])
+    }
+  })
+
+const MapPresentMarker = ({ map, presentMarker, setPresentMarker }) => {
+  const [mediaMarkers, setMediaMarkers] = useState(null)
+  const [currentIndex, setCurrentIndex] = useState(0)
+
+  const [loadMedia, { data: loadedMedia }] = useLazyQuery(QUERY_MEDIA)
+
+  useEffect(() => {
+    if (presentMarker == null || map == null) {
+      setMediaMarkers(null)
+      return
+    }
+
+    getMediaFromMarker(map, presentMarker).then(setMediaMarkers)
+  }, [presentMarker])
+
+  useEffect(() => {
+    if (!mediaMarkers) return
+
+    setCurrentIndex(0)
+    loadMedia({
+      variables: {
+        mediaIDs: mediaMarkers.map(x => x.media_id),
+      },
+    })
+  }, [mediaMarkers])
+
+  if (presentMarker == null || map == null) {
+    return null
+  }
+
+  if (loadedMedia == null) {
+    return null
+  }
+
+  return (
+    <PresentView
+      media={loadedMedia.mediaList[currentIndex]}
+      nextImage={() => {
+        setCurrentIndex(i => Math.min(mediaMarkers.length - 1, i + 1))
+      }}
+      previousImage={() => {
+        setCurrentIndex(i => Math.max(0, i - 1))
+      }}
+      setPresenting={presenting => {
+        if (!presenting) {
+          setCurrentIndex(0)
+          setPresentMarker(null)
+        }
+      }}
+    />
+  )
+}
+
+MapPresentMarker.propTypes = {
+  map: PropTypes.object,
+  presentMarker: PropTypes.object,
+  setPresentMarker: PropTypes.func.isRequired,
+}
+
+export default MapPresentMarker
diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js
new file mode 100644
index 0000000..d31e555
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/PlacesPage.js
@@ -0,0 +1,126 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { useQuery } from 'react-apollo'
+import gql from 'graphql-tag'
+import styled from 'styled-components'
+
+import 'mapbox-gl/dist/mapbox-gl.css'
+
+import Layout from '../../Layout'
+import { makeUpdateMarkers } from './mapboxHelperFunctions'
+import MapPresentMarker from './MapPresentMarker'
+
+const MapWrapper = styled.div`
+  width: 100%;
+  height: calc(100% - 24px);
+`
+
+const MapContainer = styled.div`
+  width: 100%;
+  height: 100%;
+`
+
+const MAPBOX_DATA_QUERY = gql`
+  query placePageMapboxToken {
+    mapboxToken
+    myMediaGeoJson
+  }
+`
+
+const MapPage = () => {
+  const [mapboxLibrary, setMapboxLibrary] = useState(null)
+  const [presentMarker, setPresentMarker] = useState(null)
+  const mapContainer = useRef()
+  const map = useRef()
+
+  const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY)
+
+  useEffect(() => {
+    async function loadMapboxLibrary() {
+      const mapbox = await import('mapbox-gl')
+      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,
+    })
+
+    map.current.on('load', () => {
+      map.current.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.current.addLayer({
+        id: 'media-points',
+        type: 'circle',
+        source: 'media',
+        filter: ['!', true],
+      })
+
+      const updateMarkers = makeUpdateMarkers({
+        map: map.current,
+        mapboxLibrary,
+        setPresentMarker,
+      })
+
+      map.current.on('move', updateMarkers)
+      map.current.on('moveend', updateMarkers)
+      map.current.on('sourcedata', updateMarkers)
+      updateMarkers()
+    })
+  }, [mapContainer, mapboxLibrary, mapboxData])
+
+  if (mapboxData && mapboxData.mapboxToken == null) {
+    return (
+      <Layout>
+        <h1>Mapbox token is not set</h1>
+        <p>
+          To use map related features a mapbox token is needed.
+          <br /> A mapbox token can be created for free at{' '}
+          <a href="https://account.mapbox.com/access-tokens/">mapbox.com</a>.
+        </p>
+        <p>
+          Make sure the access token is added as the MAPBOX_TOKEN environment
+          variable.
+        </p>
+      </Layout>
+    )
+  }
+
+  return (
+    <Layout>
+      <MapWrapper>
+        <MapContainer ref={mapContainer}></MapContainer>
+      </MapWrapper>
+      <MapPresentMarker
+        map={map.current}
+        presentMarker={presentMarker}
+        setPresentMarker={setPresentMarker}
+      />
+    </Layout>
+  )
+}
+
+export default MapPage
diff --git a/ui/src/Pages/PlacesPage/image-popup.svg b/ui/src/Pages/PlacesPage/image-popup.svg
new file mode 100644
index 0000000..da1249e
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/image-popup.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 56 68" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <g transform="matrix(0.875,0,0,1.0625,0,0)">
+        <g transform="matrix(1,0,0,0.970588,0,0)">
+            <g transform="matrix(0.857143,0,0,1.55496,8,-35.483)">
+                <g style="filter:url(#_Effect1);">
+                    <ellipse cx="28" cy="61.172" rx="8" ry="0.828" style="fill-opacity:0.5;"/>
+                </g>
+            </g>
+            <g transform="matrix(1.14286,0,0,0.969697,0,0)">
+                <path d="M4,54C3.47,54 2.961,53.789 2.586,53.414C2.211,53.039 2,52.53 2,52C2,43.732 2,12.268 2,4C2,3.47 2.211,2.961 2.586,2.586C2.961,2.211 3.47,2 4,2L52,2C52.53,2 53.039,2.211 53.414,2.586C53.789,2.961 54,3.47 54,4L54,52C54,52.53 53.789,53.039 53.414,53.414C53.039,53.789 52.53,54 52,54C48.077,54 39.521,54 36.828,54C36.298,54 35.789,54.211 35.414,54.586C34.161,55.839 31.192,58.808 29.414,60.586C29.039,60.961 28.53,61.172 28,61.172C27.47,61.172 26.961,60.961 26.586,60.586C24.808,58.808 21.839,55.839 20.586,54.586C20.211,54.211 19.702,54 19.172,54C16.479,54 7.923,54 4,54Z" style="fill:white;"/>
+            </g>
+        </g>
+    </g>
+    <defs>
+        <filter id="_Effect1" filterUnits="userSpaceOnUse" x="8" y="54.7306" width="40" height="12.8819">
+            <feGaussianBlur in="SourceGraphic" stdDeviation="1.1983"/>
+        </filter>
+    </defs>
+</svg>
diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js
new file mode 100644
index 0000000..32e412c
--- /dev/null
+++ b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import MapClusterMarker from './MapClusterMarker'
+
+let markers = {}
+let markersOnScreen = {}
+
+export const makeUpdateMarkers = ({
+  map,
+  mapboxLibrary,
+  setPresentMarker,
+}) => () => {
+  let newMarkers = {}
+  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 (let i = 0; i < features.length; i++) {
+    const coords = features[i].geometry.coordinates
+    const props = features[i].properties
+    const id = props.cluster
+      ? `cluster_${props.cluster_id}`
+      : `media_${props.media_id}`
+
+    let marker = markers[id]
+    if (!marker) {
+      let el = createClusterPopupElement(props, setPresentMarker)
+      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, setPresentMarker) {
+  const el = document.createElement('div')
+  ReactDOM.render(
+    <MapClusterMarker {...geojsonProps} setPresentMarker={setPresentMarker} />,
+    el
+  )
+  return el
+}
diff --git a/ui/src/Routes.js b/ui/src/Routes.js
index 948a3a4..d3b5499 100644
--- a/ui/src/Routes.js
+++ b/ui/src/Routes.js
@@ -9,6 +9,7 @@ const AlbumsPage = React.lazy(() => import('./Pages/AllAlbumsPage/AlbumsPage'))
 const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage'))
 const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute'))
 const PhotosPage = React.lazy(() => import('./Pages/PhotosPage/PhotosPage'))
+const PlacesPage = React.lazy(() => import('./Pages/PlacesPage/PlacesPage'))
 const SharePage = React.lazy(() => import('./Pages/SharePage/SharePage'))
 
 const LoginPage = React.lazy(() => import('./Pages/LoginPage/LoginPage'))
@@ -43,6 +44,7 @@ class Routes extends React.Component {
           <AuthorizedRoute exact path="/albums" component={AlbumsPage} />
           <AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} />
           <AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} />
+          <AuthorizedRoute path="/places" component={PlacesPage} />
           <AuthorizedRoute admin path="/settings" component={SettingsPage} />
           <Route path="/" exact render={() => <Redirect to="/photos" />} />
           <Route render={() => <div>Page not found</div>} />
diff --git a/ui/src/components/photoGallery/PhotoGallery.js b/ui/src/components/photoGallery/PhotoGallery.js
index f490c3a..b064c35 100644
--- a/ui/src/components/photoGallery/PhotoGallery.js
+++ b/ui/src/components/photoGallery/PhotoGallery.js
@@ -44,32 +44,6 @@ const PhotoGallery = ({
 }) => {
   const { updateSidebar } = useContext(SidebarContext)
 
-  useEffect(() => {
-    const keyDownEvent = e => {
-      if (!onSelectImage || activeIndex == -1) {
-        return
-      }
-
-      if (e.key == 'ArrowRight') {
-        nextImage && nextImage()
-      }
-
-      if (e.key == 'ArrowLeft') {
-        nextImage && previousImage()
-      }
-
-      if (e.key == 'Escape' && presenting) {
-        setPresenting(false)
-      }
-    }
-
-    document.addEventListener('keydown', keyDownEvent)
-
-    return function cleanup() {
-      document.removeEventListener('keydown', keyDownEvent)
-    }
-  })
-
   const activeImage = media && activeIndex != -1 && media[activeIndex]
 
   const getPhotoElements = updateSidebar => {
diff --git a/ui/src/components/photoGallery/presentView/PresentView.js b/ui/src/components/photoGallery/presentView/PresentView.js
index dacb8c4..1650188 100644
--- a/ui/src/components/photoGallery/presentView/PresentView.js
+++ b/ui/src/components/photoGallery/presentView/PresentView.js
@@ -1,5 +1,5 @@
+import React, { useEffect } from 'react'
 import PropTypes from 'prop-types'
-import React from 'react'
 import styled, { createGlobalStyle } from 'styled-components'
 import PresentNavigationOverlay from './PresentNavigationOverlay'
 import PresentMedia from './PresentMedia'
@@ -28,14 +28,43 @@ const PresentView = ({
   nextImage,
   previousImage,
   setPresenting,
-}) => (
-  <StyledContainer {...className}>
-    <PreventScroll />
-    <PresentNavigationOverlay {...{ nextImage, previousImage, setPresenting }}>
-      <PresentMedia media={media} imageLoaded={imageLoaded} />
-    </PresentNavigationOverlay>
-  </StyledContainer>
-)
+}) => {
+  useEffect(() => {
+    const keyDownEvent = e => {
+      if (e.key == 'ArrowRight') {
+        nextImage && nextImage()
+        e.stopPropagation()
+      }
+
+      if (e.key == 'ArrowLeft') {
+        nextImage && previousImage()
+        e.stopPropagation()
+      }
+
+      if (e.key == 'Escape') {
+        setPresenting(false)
+        e.stopPropagation()
+      }
+    }
+
+    document.addEventListener('keydown', keyDownEvent)
+
+    return function cleanup() {
+      document.removeEventListener('keydown', keyDownEvent)
+    }
+  })
+
+  return (
+    <StyledContainer {...className}>
+      <PreventScroll />
+      <PresentNavigationOverlay
+        {...{ nextImage, previousImage, setPresenting }}
+      >
+        <PresentMedia media={media} imageLoaded={imageLoaded} />
+      </PresentNavigationOverlay>
+    </StyledContainer>
+  )
+}
 
 PresentView.propTypes = {
   media: PropTypes.object.isRequired,
