diff --git a/docs/content/doc/packages/npm.en-us.md b/docs/content/doc/packages/npm.en-us.md
index 122f306ee..decb3f743 100644
--- a/docs/content/doc/packages/npm.en-us.md
+++ b/docs/content/doc/packages/npm.en-us.md
@@ -127,6 +127,10 @@ npm dist-tag add test_package@1.0.2 release
 
 The tag name must not be a valid version. All tag names which are parsable as a version are rejected.
 
+## Search packages
+
+The registry supports [searching](https://docs.npmjs.com/cli/v7/commands/npm-search/) but does not support special search qualifiers like `author:gitea`.
+
 ## Supported commands
 
 ```
@@ -136,4 +140,5 @@ npm publish
 npm unpublish
 npm dist-tag
 npm view
+npm search
 ```
diff --git a/modules/packages/npm/creator.go b/modules/packages/npm/creator.go
index 88ce55ecd..2ed4d2624 100644
--- a/modules/packages/npm/creator.go
+++ b/modules/packages/npm/creator.go
@@ -96,6 +96,34 @@ type PackageDistribution struct {
 	NpmSignature string `json:"npm-signature,omitempty"`
 }
 
+type PackageSearch struct {
+	Objects []*PackageSearchObject `json:"objects"`
+	Total   int64                  `json:"total"`
+}
+
+type PackageSearchObject struct {
+	Package *PackageSearchPackage `json:"package"`
+}
+
+type PackageSearchPackage struct {
+	Scope       string                     `json:"scope"`
+	Name        string                     `json:"name"`
+	Version     string                     `json:"version"`
+	Date        time.Time                  `json:"date"`
+	Description string                     `json:"description"`
+	Author      User                       `json:"author"`
+	Publisher   User                       `json:"publisher"`
+	Maintainers []User                     `json:"maintainers"`
+	Keywords    []string                   `json:"keywords,omitempty"`
+	Links       *PackageSearchPackageLinks `json:"links"`
+}
+
+type PackageSearchPackageLinks struct {
+	Registry   string `json:"npm"`
+	Homepage   string `json:"homepage,omitempty"`
+	Repository string `json:"repository,omitempty"`
+}
+
 // User https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#package
 type User struct {
 	Username string `json:"username,omitempty"`
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index 93f9afca7..cb9b3b78a 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -236,6 +236,9 @@ func Routes(ctx gocontext.Context) *web.Route {
 					r.Delete("", npm.DeletePackageTag)
 				}, reqPackageAccess(perm.AccessModeWrite))
 			})
+			r.Group("/-/v1/search", func() {
+				r.Get("", npm.PackageSearch)
+			})
 		})
 		r.Group("/pub", func() {
 			r.Group("/api/packages", func() {
diff --git a/routers/api/packages/npm/api.go b/routers/api/packages/npm/api.go
index 763c59515..d1027763a 100644
--- a/routers/api/packages/npm/api.go
+++ b/routers/api/packages/npm/api.go
@@ -74,3 +74,38 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
 		},
 	}
 }
+
+func createPackageSearchResponse(pds []*packages_model.PackageDescriptor, total int64) *npm_module.PackageSearch {
+	objects := make([]*npm_module.PackageSearchObject, 0, len(pds))
+	for _, pd := range pds {
+		metadata := pd.Metadata.(*npm_module.Metadata)
+
+		scope := metadata.Scope
+		if scope == "" {
+			scope = "unscoped"
+		}
+
+		objects = append(objects, &npm_module.PackageSearchObject{
+			Package: &npm_module.PackageSearchPackage{
+				Scope:       scope,
+				Name:        metadata.Name,
+				Version:     pd.Version.Version,
+				Date:        pd.Version.CreatedUnix.AsLocalTime(),
+				Description: metadata.Description,
+				Author:      npm_module.User{Name: metadata.Author},
+				Publisher:   npm_module.User{Name: pd.Owner.Name},
+				Maintainers: []npm_module.User{}, // npm cli needs this field
+				Keywords:    metadata.Keywords,
+				Links: &npm_module.PackageSearchPackageLinks{
+					Registry: pd.FullWebLink(),
+					Homepage: metadata.ProjectURL,
+				},
+			},
+		})
+	}
+
+	return &npm_module.PackageSearch{
+		Objects: objects,
+		Total:   total,
+	}
+}
diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go
index 66b999d47..2989ce6e7 100644
--- a/routers/api/packages/npm/npm.go
+++ b/routers/api/packages/npm/npm.go
@@ -350,3 +350,35 @@ func setPackageTag(tag string, pv *packages_model.PackageVersion, deleteOnly boo
 
 	return committer.Commit()
 }
+
+func PackageSearch(ctx *context.Context) {
+	pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+		OwnerID: ctx.Package.Owner.ID,
+		Type:    packages_model.TypeNpm,
+		Name: packages_model.SearchValue{
+			ExactMatch: false,
+			Value:      ctx.FormTrim("text"),
+		},
+		Paginator: db.NewAbsoluteListOptions(
+			ctx.FormInt("from"),
+			ctx.FormInt("size"),
+		),
+	})
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+	if err != nil {
+		apiError(ctx, http.StatusInternalServerError, err)
+		return
+	}
+
+	resp := createPackageSearchResponse(
+		pds,
+		total,
+	)
+
+	ctx.JSON(http.StatusOK, resp)
+}
diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go
index fe6cea1cb..14ed681be 100644
--- a/tests/integration/api_packages_npm_test.go
+++ b/tests/integration/api_packages_npm_test.go
@@ -224,6 +224,37 @@ func TestPackageNpm(t *testing.T) {
 		test(t, http.StatusOK, packageTag2)
 	})
 
+	t.Run("Search", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		url := fmt.Sprintf("/api/packages/%s/npm/-/v1/search", user.Name)
+
+		cases := []struct {
+			Query           string
+			Skip            int
+			Take            int
+			ExpectedTotal   int64
+			ExpectedResults int
+		}{
+			{"", 0, 0, 1, 1},
+			{"", 0, 10, 1, 1},
+			{"gitea", 0, 10, 0, 0},
+			{"test", 0, 10, 1, 1},
+			{"test", 1, 10, 1, 0},
+		}
+
+		for i, c := range cases {
+			req := NewRequest(t, "GET", fmt.Sprintf("%s?text=%s&from=%d&size=%d", url, c.Query, c.Skip, c.Take))
+			resp := MakeRequest(t, req, http.StatusOK)
+
+			var result npm.PackageSearch
+			DecodeJSON(t, resp, &result)
+
+			assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i)
+			assert.Len(t, result.Objects, c.ExpectedResults, "case %d: unexpected result count", i)
+		}
+	})
+
 	t.Run("Delete", func(t *testing.T) {
 		defer tests.PrintCurrentTest(t)()