diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 9617102..1b81f5a 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -140,6 +140,7 @@ type ComplexityRoot struct {
 		MyPhotos   func(childComplexity int, filter *models.Filter) int
 		MyUser     func(childComplexity int) int
 		Photo      func(childComplexity int, id int) int
+		Search     func(childComplexity int, query string, limitPhotos *int, limitAlbums *int) int
 		ShareToken func(childComplexity int, token string, password *string) int
 		SiteInfo   func(childComplexity int) int
 		User       func(childComplexity int, filter *models.Filter) int
@@ -152,6 +153,12 @@ type ComplexityRoot struct {
 		Success  func(childComplexity int) int
 	}
 
+	SearchResult struct {
+		Albums func(childComplexity int) int
+		Photos func(childComplexity int) int
+		Query  func(childComplexity int) int
+	}
+
 	ShareToken struct {
 		Album  func(childComplexity int) int
 		Expire func(childComplexity int) int
@@ -216,6 +223,7 @@ type QueryResolver interface {
 	MyPhotos(ctx context.Context, filter *models.Filter) ([]*models.Photo, error)
 	Photo(ctx context.Context, id int) (*models.Photo, error)
 	ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
+	Search(ctx context.Context, query string, limitPhotos *int, limitAlbums *int) (*models.SearchResult, error)
 }
 type ShareTokenResolver interface {
 	Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error)
@@ -770,6 +778,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Query.Photo(childComplexity, args["id"].(int)), true
 
+	case "Query.search":
+		if e.complexity.Query.Search == nil {
+			break
+		}
+
+		args, err := ec.field_Query_search_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Query.Search(childComplexity, args["query"].(string), args["limitPhotos"].(*int), args["limitAlbums"].(*int)), true
+
 	case "Query.shareToken":
 		if e.complexity.Query.ShareToken == nil {
 			break
@@ -829,6 +849,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.ScannerResult.Success(childComplexity), true
 
+	case "SearchResult.albums":
+		if e.complexity.SearchResult.Albums == nil {
+			break
+		}
+
+		return e.complexity.SearchResult.Albums(childComplexity), true
+
+	case "SearchResult.photos":
+		if e.complexity.SearchResult.Photos == nil {
+			break
+		}
+
+		return e.complexity.SearchResult.Photos(childComplexity), true
+
+	case "SearchResult.query":
+		if e.complexity.SearchResult.Query == nil {
+			break
+		}
+
+		return e.complexity.SearchResult.Query(childComplexity), true
+
 	case "ShareToken.album":
 		if e.complexity.ShareToken.Album == nil {
 			break
@@ -1045,6 +1086,8 @@ type Query {
   photo(id: Int!): Photo!
 
   shareToken(token: String!, password: String): ShareToken!
+
+  search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
 }
 
 type Mutation {
@@ -1234,6 +1277,12 @@ type PhotoEXIF {
   "An index describing the mode for adjusting the exposure of the image"
   exposureProgram: Int
 }
+
+type SearchResult {
+  query: String!
+  albums: [Album!]!
+  photos: [Photo!]!
+}
 `},
 )
 
@@ -1623,6 +1672,36 @@ func (ec *executionContext) field_Query_photo_args(ctx context.Context, rawArgs
 	return args, nil
 }
 
+func (ec *executionContext) field_Query_search_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	var arg0 string
+	if tmp, ok := rawArgs["query"]; ok {
+		arg0, err = ec.unmarshalNString2string(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["query"] = arg0
+	var arg1 *int
+	if tmp, ok := rawArgs["limitPhotos"]; ok {
+		arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["limitPhotos"] = arg1
+	var arg2 *int
+	if tmp, ok := rawArgs["limitAlbums"]; ok {
+		arg2, err = ec.unmarshalOInt2ᚖint(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["limitAlbums"] = arg2
+	return args, nil
+}
+
 func (ec *executionContext) field_Query_shareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -4325,6 +4404,50 @@ func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql
 	return ec.marshalNShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Query_search(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+		ec.Tracer.EndFieldExecution(ctx)
+	}()
+	rctx := &graphql.ResolverContext{
+		Object:   "Query",
+		Field:    field,
+		Args:     nil,
+		IsMethod: true,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	rawArgs := field.ArgumentMap(ec.Variables)
+	args, err := ec.field_Query_search_args(ctx, rawArgs)
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	rctx.Args = args
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Query().Search(rctx, args["query"].(string), args["limitPhotos"].(*int), args["limitAlbums"].(*int))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*models.SearchResult)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNSearchResult2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	ctx = ec.Tracer.StartFieldExecution(ctx, field)
 	defer func() {
@@ -4542,6 +4665,117 @@ func (ec *executionContext) _ScannerResult_message(ctx context.Context, field gr
 	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _SearchResult_query(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+		ec.Tracer.EndFieldExecution(ctx)
+	}()
+	rctx := &graphql.ResolverContext{
+		Object:   "SearchResult",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Query, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _SearchResult_albums(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+		ec.Tracer.EndFieldExecution(ctx)
+	}()
+	rctx := &graphql.ResolverContext{
+		Object:   "SearchResult",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Albums, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.Album)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNAlbum2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) _SearchResult_photos(ctx context.Context, field graphql.CollectedField, obj *models.SearchResult) (ret graphql.Marshaler) {
+	ctx = ec.Tracer.StartFieldExecution(ctx, field)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+		ec.Tracer.EndFieldExecution(ctx)
+	}()
+	rctx := &graphql.ResolverContext{
+		Object:   "SearchResult",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+	ctx = graphql.WithResolverContext(ctx, rctx)
+	ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Photos, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !ec.HasError(rctx) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.Photo)
+	rctx.Result = res
+	ctx = ec.Tracer.StartFieldChildExecution(ctx)
+	return ec.marshalNPhoto2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhotoᚄ(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _ShareToken_id(ctx context.Context, field graphql.CollectedField, obj *models.ShareToken) (ret graphql.Marshaler) {
 	ctx = ec.Tracer.StartFieldExecution(ctx, field)
 	defer func() {
@@ -6836,6 +7070,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 				}
 				return res
 			})
+		case "search":
+			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_search(ctx, field)
+				if res == graphql.Null {
+					atomic.AddUint32(&invalids, 1)
+				}
+				return res
+			})
 		case "__type":
 			out.Values[i] = ec._Query___type(ctx, field)
 		case "__schema":
@@ -6887,6 +7135,43 @@ func (ec *executionContext) _ScannerResult(ctx context.Context, sel ast.Selectio
 	return out
 }
 
+var searchResultImplementors = []string{"SearchResult"}
+
+func (ec *executionContext) _SearchResult(ctx context.Context, sel ast.SelectionSet, obj *models.SearchResult) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.RequestContext, sel, searchResultImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	var invalids uint32
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("SearchResult")
+		case "query":
+			out.Values[i] = ec._SearchResult_query(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		case "albums":
+			out.Values[i] = ec._SearchResult_albums(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				invalids++
+			}
+		case "photos":
+			out.Values[i] = ec._SearchResult_photos(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 shareTokenImplementors = []string{"ShareToken"}
 
 func (ec *executionContext) _ShareToken(ctx context.Context, sel ast.SelectionSet, obj *models.ShareToken) graphql.Marshaler {
@@ -7537,6 +7822,20 @@ func (ec *executionContext) marshalNScannerResult2ᚖgithubᚗcomᚋviktorstrate
 	return ec._ScannerResult(ctx, sel, v)
 }
 
+func (ec *executionContext) marshalNSearchResult2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx context.Context, sel ast.SelectionSet, v models.SearchResult) graphql.Marshaler {
+	return ec._SearchResult(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNSearchResult2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐSearchResult(ctx context.Context, sel ast.SelectionSet, v *models.SearchResult) graphql.Marshaler {
+	if v == nil {
+		if !ec.HasError(graphql.GetResolverContext(ctx)) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	return ec._SearchResult(ctx, sel, v)
+}
+
 func (ec *executionContext) marshalNShareToken2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx context.Context, sel ast.SelectionSet, v models.ShareToken) graphql.Marshaler {
 	return ec._ShareToken(ctx, sel, &v)
 }
diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go
index 7f6c3d9..e4070c7 100644
--- a/api/graphql/models/generated.go
+++ b/api/graphql/models/generated.go
@@ -47,6 +47,12 @@ type ScannerResult struct {
 	Message  *string  `json:"message"`
 }
 
+type SearchResult struct {
+	Query  string   `json:"query"`
+	Albums []*Album `json:"albums"`
+	Photos []*Photo `json:"photos"`
+}
+
 // General public information about the site
 type SiteInfo struct {
 	InitialSetup bool `json:"initialSetup"`
diff --git a/api/graphql/resolvers/search.go b/api/graphql/resolvers/search.go
new file mode 100644
index 0000000..a8b26ce
--- /dev/null
+++ b/api/graphql/resolvers/search.go
@@ -0,0 +1,75 @@
+package resolvers
+
+import (
+	"context"
+	"log"
+
+	"github.com/viktorstrate/photoview/api/graphql/auth"
+	"github.com/viktorstrate/photoview/api/graphql/models"
+)
+
+func (r *Resolver) Search(ctx context.Context, query string, _limitPhotos *int, _limitAlbums *int) (*models.SearchResult, error) {
+	user := auth.UserFromContext(ctx)
+	if user == nil {
+		return nil, auth.ErrUnauthorized
+	}
+
+	limitPhotos := 10
+	limitAlbums := 10
+
+	if _limitPhotos != nil {
+		limitPhotos = *_limitPhotos
+	}
+
+	if _limitAlbums != nil {
+		limitAlbums = *_limitAlbums
+	}
+
+	wildQuery := "%" + query + "%"
+
+	photoRows, err := r.Database.Query(`
+		SELECT photo.* FROM photo JOIN album ON photo.album_id = album.album_id
+		WHERE album.owner_id = ? AND photo.title LIKE ? OR photo.path LIKE ?
+		ORDER BY (
+			case when photo.title LIKE ? then 2
+			     when photo.path LIKE ? then 1
+			end ) DESC
+		LIMIT ?
+	`, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitPhotos)
+	if err != nil {
+		log.Printf("ERROR: searching photos %s", err)
+		return nil, err
+	}
+
+	photos, err := models.NewPhotosFromRows(photoRows)
+	if err != nil {
+		return nil, err
+	}
+
+	albumRows, err := r.Database.Query(`
+		SELECT * FROM album
+		WHERE owner_id = ? AND title LIKE ? OR path LIKE ?
+		ORDER BY (
+			case when title LIKE ? then 2
+			     when path LIKE ? then 1
+			end ) DESC
+		LIMIT ?
+	`, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitAlbums)
+	if err != nil {
+		log.Printf("ERROR: searching albums %s", err)
+		return nil, err
+	}
+
+	albums, err := models.NewAlbumsFromRows(albumRows)
+	if err != nil {
+		return nil, err
+	}
+
+	result := models.SearchResult{
+		Query:  query,
+		Photos: photos,
+		Albums: albums,
+	}
+
+	return &result, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index aa26b29..ce2c441 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -39,6 +39,8 @@ type Query {
   photo(id: Int!): Photo!
 
   shareToken(token: String!, password: String): ShareToken!
+
+  search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
 }
 
 type Mutation {
@@ -228,3 +230,9 @@ type PhotoEXIF {
   "An index describing the mode for adjusting the exposure of the image"
   exposureProgram: Int
 }
+
+type SearchResult {
+  query: String!
+  albums: [Album!]!
+  photos: [Photo!]!
+}
