diff --git a/integrations/api_repo_topic_test.go b/integrations/api_repo_topic_test.go
new file mode 100644
index 0000000000..34c33d1b25
--- /dev/null
+++ b/integrations/api_repo_topic_test.go
@@ -0,0 +1,124 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package integrations
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoTopic(t *testing.T) {
+	prepareTestEnv(t)
+	user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of repo2
+	user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of repo3
+	user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // write access to repo 3
+	repo2 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 2}).(*models.Repository)
+	repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
+
+	// Get user2's token
+	session := loginUser(t, user2.Name)
+	token2 := getTokenForLoggedInUser(t, session)
+
+	// Test read topics using login
+	url := fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name)
+	req := NewRequest(t, "GET", url)
+	res := session.MakeRequest(t, req, http.StatusOK)
+	var topics *api.TopicName
+	DecodeJSON(t, res, &topics)
+	assert.ElementsMatch(t, []string{"topicname1", "topicname2"}, topics.TopicNames)
+
+	// Log out user2
+	session = emptyTestSession(t)
+	url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user2.Name, repo2.Name, token2)
+
+	// Test delete a topic
+	req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2)
+	res = session.MakeRequest(t, req, http.StatusNoContent)
+
+	// Test add an existing topic
+	req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Golang", token2)
+	res = session.MakeRequest(t, req, http.StatusNoContent)
+
+	// Test add a topic
+	req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "topicName3", token2)
+	res = session.MakeRequest(t, req, http.StatusNoContent)
+
+	// Test read topics using token
+	req = NewRequest(t, "GET", url)
+	res = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, res, &topics)
+	assert.ElementsMatch(t, []string{"topicname2", "golang", "topicname3"}, topics.TopicNames)
+
+	// Test replace topics
+	newTopics := []string{"   windows ", "   ", "MAC  "}
+	req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+		Topics: newTopics,
+	})
+	res = session.MakeRequest(t, req, http.StatusNoContent)
+	req = NewRequest(t, "GET", url)
+	res = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, res, &topics)
+	assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
+
+	// Test replace topics with something invalid
+	newTopics = []string{"topicname1", "topicname2", "topicname!"}
+	req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+		Topics: newTopics,
+	})
+	res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+	req = NewRequest(t, "GET", url)
+	res = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, res, &topics)
+	assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
+
+	// Test with some topics multiple times, less than 25 unique
+	newTopics = []string{"t1", "t2", "t1", "t3", "t4", "t5", "t6", "t7", "t8", "t9", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25"}
+	req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+		Topics: newTopics,
+	})
+	res = session.MakeRequest(t, req, http.StatusNoContent)
+	req = NewRequest(t, "GET", url)
+	res = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, res, &topics)
+	assert.Equal(t, 25, len(topics.TopicNames))
+
+	// Test writing more topics than allowed
+	newTopics = append(newTopics, "t26")
+	req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+		Topics: newTopics,
+	})
+	res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+	// Test add a topic when there is already maximum
+	req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "t26", token2)
+	res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+	// Test delete a topic that repo doesn't have
+	req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2)
+	res = session.MakeRequest(t, req, http.StatusNotFound)
+
+	// Get user4's token
+	session = loginUser(t, user4.Name)
+	token4 := getTokenForLoggedInUser(t, session)
+	session = emptyTestSession(t)
+
+	// Test read topics with write access
+	url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user3.Name, repo3.Name, token4)
+	req = NewRequest(t, "GET", url)
+	res = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, res, &topics)
+	assert.Equal(t, 0, len(topics.TopicNames))
+
+	// Test add a topic to repo with write access (requires repo admin access)
+	req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user3.Name, repo3.Name, "topicName", token4)
+	res = session.MakeRequest(t, req, http.StatusForbidden)
+
+}
diff --git a/models/fixtures/repo_topic.yml b/models/fixtures/repo_topic.yml
index 7041ccfd09..f166faccc1 100644
--- a/models/fixtures/repo_topic.yml
+++ b/models/fixtures/repo_topic.yml
@@ -17,3 +17,11 @@
 -
   repo_id: 33
   topic_id: 4
+
+-
+  repo_id: 2
+  topic_id: 5
+
+-
+  repo_id: 2
+  topic_id: 6
diff --git a/models/fixtures/topic.yml b/models/fixtures/topic.yml
index c868b207cb..6cd0b37fa1 100644
--- a/models/fixtures/topic.yml
+++ b/models/fixtures/topic.yml
@@ -15,3 +15,11 @@
 - id: 4
   name: graphql
   repo_count: 1
+
+- id: 5
+  name: topicname1
+  repo_count: 1
+
+- id: 6
+  name: topicname2
+  repo_count: 2
diff --git a/models/topic.go b/models/topic.go
index 8a587acc3a..e4fda03fc4 100644
--- a/models/topic.go
+++ b/models/topic.go
@@ -54,11 +54,38 @@ func (err ErrTopicNotExist) Error() string {
 	return fmt.Sprintf("topic is not exist [name: %s]", err.Name)
 }
 
-// ValidateTopic checks topics by length and match pattern rules
+// ValidateTopic checks a topic by length and match pattern rules
 func ValidateTopic(topic string) bool {
 	return len(topic) <= 35 && topicPattern.MatchString(topic)
 }
 
+// SanitizeAndValidateTopics sanitizes and checks an array or topics
+func SanitizeAndValidateTopics(topics []string) (validTopics []string, invalidTopics []string) {
+	validTopics = make([]string, 0)
+	mValidTopics := make(map[string]struct{})
+	invalidTopics = make([]string, 0)
+
+	for _, topic := range topics {
+		topic = strings.TrimSpace(strings.ToLower(topic))
+		// ignore empty string
+		if len(topic) == 0 {
+			continue
+		}
+		// ignore same topic twice
+		if _, ok := mValidTopics[topic]; ok {
+			continue
+		}
+		if ValidateTopic(topic) {
+			validTopics = append(validTopics, topic)
+			mValidTopics[topic] = struct{}{}
+		} else {
+			invalidTopics = append(invalidTopics, topic)
+		}
+	}
+
+	return validTopics, invalidTopics
+}
+
 // GetTopicByName retrieves topic by name
 func GetTopicByName(name string) (*Topic, error) {
 	var topic Topic
@@ -70,6 +97,54 @@ func GetTopicByName(name string) (*Topic, error) {
 	return &topic, nil
 }
 
+// addTopicByNameToRepo adds a topic name to a repo and increments the topic count.
+// Returns topic after the addition
+func addTopicByNameToRepo(e Engine, repoID int64, topicName string) (*Topic, error) {
+	var topic Topic
+	has, err := e.Where("name = ?", topicName).Get(&topic)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		topic.Name = topicName
+		topic.RepoCount = 1
+		if _, err := e.Insert(&topic); err != nil {
+			return nil, err
+		}
+	} else {
+		topic.RepoCount++
+		if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
+			return nil, err
+		}
+	}
+
+	if _, err := e.Insert(&RepoTopic{
+		RepoID:  repoID,
+		TopicID: topic.ID,
+	}); err != nil {
+		return nil, err
+	}
+
+	return &topic, nil
+}
+
+// removeTopicFromRepo remove a topic from a repo and decrements the topic repo count
+func removeTopicFromRepo(repoID int64, topic *Topic, e Engine) error {
+	topic.RepoCount--
+	if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
+		return err
+	}
+
+	if _, err := e.Delete(&RepoTopic{
+		RepoID:  repoID,
+		TopicID: topic.ID,
+	}); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // FindTopicOptions represents the options when fdin topics
 type FindTopicOptions struct {
 	RepoID  int64
@@ -103,6 +178,50 @@ func FindTopics(opts *FindTopicOptions) (topics []*Topic, err error) {
 	return topics, sess.Desc("topic.repo_count").Find(&topics)
 }
 
+// GetRepoTopicByName retrives topic from name for a repo if it exist
+func GetRepoTopicByName(repoID int64, topicName string) (*Topic, error) {
+	var cond = builder.NewCond()
+	var topic Topic
+	cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName})
+	sess := x.Table("topic").Where(cond)
+	sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
+	has, err := sess.Get(&topic)
+	if has {
+		return &topic, err
+	}
+	return nil, err
+}
+
+// AddTopic adds a topic name to a repository (if it does not already have it)
+func AddTopic(repoID int64, topicName string) (*Topic, error) {
+	topic, err := GetRepoTopicByName(repoID, topicName)
+	if err != nil {
+		return nil, err
+	}
+	if topic != nil {
+		// Repo already have topic
+		return topic, nil
+	}
+
+	return addTopicByNameToRepo(x, repoID, topicName)
+}
+
+// DeleteTopic removes a topic name from a repository (if it has it)
+func DeleteTopic(repoID int64, topicName string) (*Topic, error) {
+	topic, err := GetRepoTopicByName(repoID, topicName)
+	if err != nil {
+		return nil, err
+	}
+	if topic == nil {
+		// Repo doesn't have topic, can't be removed
+		return nil, nil
+	}
+
+	err = removeTopicFromRepo(repoID, topic, x)
+
+	return topic, err
+}
+
 // SaveTopics save topics to a repository
 func SaveTopics(repoID int64, topicNames ...string) error {
 	topics, err := FindTopics(&FindTopicOptions{
@@ -152,40 +271,15 @@ func SaveTopics(repoID int64, topicNames ...string) error {
 	}
 
 	for _, topicName := range addedTopicNames {
-		var topic Topic
-		if has, err := sess.Where("name = ?", topicName).Get(&topic); err != nil {
-			return err
-		} else if !has {
-			topic.Name = topicName
-			topic.RepoCount = 1
-			if _, err := sess.Insert(&topic); err != nil {
-				return err
-			}
-		} else {
-			topic.RepoCount++
-			if _, err := sess.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
-				return err
-			}
-		}
-
-		if _, err := sess.Insert(&RepoTopic{
-			RepoID:  repoID,
-			TopicID: topic.ID,
-		}); err != nil {
+		_, err := addTopicByNameToRepo(sess, repoID, topicName)
+		if err != nil {
 			return err
 		}
 	}
 
 	for _, topic := range removeTopics {
-		topic.RepoCount--
-		if _, err := sess.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
-			return err
-		}
-
-		if _, err := sess.Delete(&RepoTopic{
-			RepoID:  repoID,
-			TopicID: topic.ID,
-		}); err != nil {
+		err := removeTopicFromRepo(repoID, topic, sess)
+		if err != nil {
 			return err
 		}
 	}
diff --git a/models/topic_test.go b/models/topic_test.go
index 65e52afb12..c173c7bf2a 100644
--- a/models/topic_test.go
+++ b/models/topic_test.go
@@ -11,11 +11,15 @@ import (
 )
 
 func TestAddTopic(t *testing.T) {
+	totalNrOfTopics := 6
+	repo1NrOfTopics := 3
+	repo2NrOfTopics := 2
+
 	assert.NoError(t, PrepareTestDatabase())
 
 	topics, err := FindTopics(&FindTopicOptions{})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 4, len(topics))
+	assert.EqualValues(t, totalNrOfTopics, len(topics))
 
 	topics, err = FindTopics(&FindTopicOptions{
 		Limit: 2,
@@ -27,33 +31,36 @@ func TestAddTopic(t *testing.T) {
 		RepoID: 1,
 	})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 3, len(topics))
+	assert.EqualValues(t, repo1NrOfTopics, len(topics))
 
 	assert.NoError(t, SaveTopics(2, "golang"))
+	repo2NrOfTopics = 1
 	topics, err = FindTopics(&FindTopicOptions{})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 4, len(topics))
+	assert.EqualValues(t, totalNrOfTopics, len(topics))
 
 	topics, err = FindTopics(&FindTopicOptions{
 		RepoID: 2,
 	})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 1, len(topics))
+	assert.EqualValues(t, repo2NrOfTopics, len(topics))
 
 	assert.NoError(t, SaveTopics(2, "golang", "gitea"))
+	repo2NrOfTopics = 2
+	totalNrOfTopics++
 	topic, err := GetTopicByName("gitea")
 	assert.NoError(t, err)
 	assert.EqualValues(t, 1, topic.RepoCount)
 
 	topics, err = FindTopics(&FindTopicOptions{})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 5, len(topics))
+	assert.EqualValues(t, totalNrOfTopics, len(topics))
 
 	topics, err = FindTopics(&FindTopicOptions{
 		RepoID: 2,
 	})
 	assert.NoError(t, err)
-	assert.EqualValues(t, 2, len(topics))
+	assert.EqualValues(t, repo2NrOfTopics, len(topics))
 }
 
 func TestTopicValidator(t *testing.T) {
diff --git a/modules/structs/repo_topic.go b/modules/structs/repo_topic.go
new file mode 100644
index 0000000000..294d56a953
--- /dev/null
+++ b/modules/structs/repo_topic.go
@@ -0,0 +1,29 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package structs
+
+import (
+	"time"
+)
+
+// TopicResponse for returning topics
+type TopicResponse struct {
+	ID        int64     `json:"id"`
+	Name      string    `json:"topic_name"`
+	RepoCount int       `json:"repo_count"`
+	Created   time.Time `json:"created"`
+	Updated   time.Time `json:"updated"`
+}
+
+// TopicName a list of repo topic names
+type TopicName struct {
+	TopicNames []string `json:"topics"`
+}
+
+// RepoTopicOptions a collection of repo topic names
+type RepoTopicOptions struct {
+	// list of topic names
+	Topics []string `json:"topics"`
+}
diff --git a/public/js/index.js b/public/js/index.js
index 15f8d02bbd..882f19e13d 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -2936,14 +2936,14 @@ function initTopicbar() {
                     let found = false;
                     for (let i=0;i < res.topics.length;i++) {
                         // skip currently added tags
-                        if (current_topics.indexOf(res.topics[i].Name) != -1){
+                        if (current_topics.indexOf(res.topics[i].topic_name) != -1){
                             continue;
                         }
 
-                        if (res.topics[i].Name.toLowerCase() === query.toLowerCase()){
+                        if (res.topics[i].topic_name.toLowerCase() === query.toLowerCase()){
                             found_query = true;
                         }
-                        formattedResponse.results.push({"description": res.topics[i].Name, "data-value": res.topics[i].Name});
+                        formattedResponse.results.push({"description": res.topics[i].topic_name, "data-value": res.topics[i].topic_name});
                         found = true;
                     }
                     formattedResponse.success = found;
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 2842d78cd3..c57edf6a99 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -771,6 +771,14 @@ func RegisterRoutes(m *macaron.Macaron) {
 						m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile)
 					}, reqRepoWriter(models.UnitTypeCode), reqToken())
 				}, reqRepoReader(models.UnitTypeCode))
+				m.Group("/topics", func() {
+					m.Combo("").Get(repo.ListTopics).
+						Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics)
+					m.Group("/:topic", func() {
+						m.Combo("").Put(reqToken(), repo.AddTopic).
+							Delete(reqToken(), repo.DeleteTopic)
+					}, reqAdmin())
+				}, reqAnyRepoReader())
 			}, repoAssignment())
 		})
 
diff --git a/routers/api/v1/convert/convert.go b/routers/api/v1/convert/convert.go
index 90202117cc..40e4ca7ae3 100644
--- a/routers/api/v1/convert/convert.go
+++ b/routers/api/v1/convert/convert.go
@@ -291,3 +291,14 @@ func ToCommitMeta(repo *models.Repository, tag *git.Tag) *api.CommitMeta {
 		URL: util.URLJoin(repo.APIURL(), "git/commits", tag.ID.String()),
 	}
 }
+
+// ToTopicResponse convert from models.Topic to api.TopicResponse
+func ToTopicResponse(topic *models.Topic) *api.TopicResponse {
+	return &api.TopicResponse{
+		ID:        topic.ID,
+		Name:      topic.Name,
+		RepoCount: topic.RepoCount,
+		Created:   topic.CreatedUnix.AsTime(),
+		Updated:   topic.UpdatedUnix.AsTime(),
+	}
+}
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index eccff8c387..82bfa58b7a 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -871,45 +871,3 @@ func MirrorSync(ctx *context.APIContext) {
 	go models.MirrorQueue.Add(repo.ID)
 	ctx.Status(200)
 }
-
-// TopicSearch search for creating topic
-func TopicSearch(ctx *context.Context) {
-	// swagger:operation GET /topics/search repository topicSearch
-	// ---
-	// summary: search topics via keyword
-	// produces:
-	//   - application/json
-	// parameters:
-	//   - name: q
-	//     in: query
-	//     description: keywords to search
-	//     required: true
-	//     type: string
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/Repository"
-	if ctx.User == nil {
-		ctx.JSON(403, map[string]interface{}{
-			"message": "Only owners could change the topics.",
-		})
-		return
-	}
-
-	kw := ctx.Query("q")
-
-	topics, err := models.FindTopics(&models.FindTopicOptions{
-		Keyword: kw,
-		Limit:   10,
-	})
-	if err != nil {
-		log.Error("SearchTopics failed: %v", err)
-		ctx.JSON(500, map[string]interface{}{
-			"message": "Search topics failed.",
-		})
-		return
-	}
-
-	ctx.JSON(200, map[string]interface{}{
-		"topics": topics,
-	})
-}
diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go
new file mode 100644
index 0000000000..6c3ac0020a
--- /dev/null
+++ b/routers/api/v1/repo/topic.go
@@ -0,0 +1,274 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package repo
+
+import (
+	"net/http"
+	"strings"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/log"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/routers/api/v1/convert"
+)
+
+// ListTopics returns list of current topics for repo
+func ListTopics(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/topics repository repoListTopics
+	// ---
+	// summary: Get list of topics that a repository has
+	// produces:
+	//   - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/TopicNames"
+
+	topics, err := models.FindTopics(&models.FindTopicOptions{
+		RepoID: ctx.Repo.Repository.ID,
+	})
+	if err != nil {
+		log.Error("ListTopics failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "ListTopics failed.",
+		})
+		return
+	}
+
+	topicNames := make([]string, len(topics))
+	for i, topic := range topics {
+		topicNames[i] = topic.Name
+	}
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"topics": topicNames,
+	})
+}
+
+// UpdateTopics updates repo with a new set of topics
+func UpdateTopics(ctx *context.APIContext, form api.RepoTopicOptions) {
+	// swagger:operation PUT /repos/{owner}/{repo}/topics repository repoUpdateTopics
+	// ---
+	// summary: Replace list of topics for a repository
+	// produces:
+	//   - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/RepoTopicOptions"
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+
+	topicNames := form.Topics
+	validTopics, invalidTopics := models.SanitizeAndValidateTopics(topicNames)
+
+	if len(validTopics) > 25 {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{
+			"invalidTopics": nil,
+			"message":       "Exceeding maximum number of topics per repo",
+		})
+		return
+	}
+
+	if len(invalidTopics) > 0 {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{
+			"invalidTopics": invalidTopics,
+			"message":       "Topic names are invalid",
+		})
+		return
+	}
+
+	err := models.SaveTopics(ctx.Repo.Repository.ID, validTopics...)
+	if err != nil {
+		log.Error("SaveTopics failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "Save topics failed.",
+		})
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// AddTopic adds a topic name to a repo
+func AddTopic(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/topics/{topic} repository repoAddTopĆ­c
+	// ---
+	// summary: Add a topic to a repository
+	// produces:
+	//   - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: topic
+	//   in: path
+	//   description: name of the topic to add
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+
+	topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic")))
+
+	if !models.ValidateTopic(topicName) {
+		ctx.Error(http.StatusUnprocessableEntity, "", "Topic name is invalid")
+		return
+	}
+
+	// Prevent adding more topics than allowed to repo
+	topics, err := models.FindTopics(&models.FindTopicOptions{
+		RepoID: ctx.Repo.Repository.ID,
+	})
+	if err != nil {
+		log.Error("AddTopic failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "ListTopics failed.",
+		})
+		return
+	}
+	if len(topics) >= 25 {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{
+			"message": "Exceeding maximum allowed topics per repo.",
+		})
+		return
+	}
+
+	_, err = models.AddTopic(ctx.Repo.Repository.ID, topicName)
+	if err != nil {
+		log.Error("AddTopic failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "AddTopic failed.",
+		})
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// DeleteTopic removes topic name from repo
+func DeleteTopic(ctx *context.APIContext) {
+	// swagger:operation DELETE /repos/{owner}/{repo}/topics/{topic} repository repoDeleteTopic
+	// ---
+	// summary: Delete a topic from a repository
+	// produces:
+	//   - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: topic
+	//   in: path
+	//   description: name of the topic to delete
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic")))
+
+	if !models.ValidateTopic(topicName) {
+		ctx.Error(http.StatusUnprocessableEntity, "", "Topic name is invalid")
+		return
+	}
+
+	topic, err := models.DeleteTopic(ctx.Repo.Repository.ID, topicName)
+	if err != nil {
+		log.Error("DeleteTopic failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "DeleteTopic failed.",
+		})
+		return
+	}
+
+	if topic == nil {
+		ctx.NotFound()
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+// TopicSearch search for creating topic
+func TopicSearch(ctx *context.Context) {
+	// swagger:operation GET /topics/search repository topicSearch
+	// ---
+	// summary: search topics via keyword
+	// produces:
+	//   - application/json
+	// parameters:
+	//   - name: q
+	//     in: query
+	//     description: keywords to search
+	//     required: true
+	//     type: string
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/TopicListResponse"
+	if ctx.User == nil {
+		ctx.JSON(http.StatusForbidden, map[string]interface{}{
+			"message": "Only owners could change the topics.",
+		})
+		return
+	}
+
+	kw := ctx.Query("q")
+
+	topics, err := models.FindTopics(&models.FindTopicOptions{
+		Keyword: kw,
+		Limit:   10,
+	})
+	if err != nil {
+		log.Error("SearchTopics failed: %v", err)
+		ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+			"message": "Search topics failed.",
+		})
+		return
+	}
+
+	topicResponses := make([]*api.TopicResponse, len(topics))
+	for i, topic := range topics {
+		topicResponses[i] = convert.ToTopicResponse(topic)
+	}
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"topics": topicResponses,
+	})
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index c1196eeb71..80e4bf422a 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -117,4 +117,7 @@ type swaggerParameterBodies struct {
 
 	// in:body
 	DeleteFileOptions api.DeleteFileOptions
+
+	// in:body
+	RepoTopicOptions api.RepoTopicOptions
 }
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 422cc0861c..4ac5c6d2d5 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -246,3 +246,17 @@ type swaggerFileDeleteResponse struct {
 	//in: body
 	Body api.FileDeleteResponse `json:"body"`
 }
+
+// TopicListResponse
+// swagger:response TopicListResponse
+type swaggerTopicListResponse struct {
+	//in: body
+	Body []api.TopicResponse `json:"body"`
+}
+
+// TopicNames
+// swagger:response TopicNames
+type swaggerTopicNames struct {
+	//in: body
+	Body api.TopicName `json:"body"`
+}
diff --git a/routers/repo/topic.go b/routers/repo/topic.go
index 4a1194bc2d..b23023ceba 100644
--- a/routers/repo/topic.go
+++ b/routers/repo/topic.go
@@ -27,24 +27,11 @@ func TopicsPost(ctx *context.Context) {
 		topics = strings.Split(topicsStr, ",")
 	}
 
-	invalidTopics := make([]string, 0)
-	i := 0
-	for _, topic := range topics {
-		topic = strings.TrimSpace(strings.ToLower(topic))
-		// ignore empty string
-		if len(topic) > 0 {
-			topics[i] = topic
-			i++
-		}
-		if !models.ValidateTopic(topic) {
-			invalidTopics = append(invalidTopics, topic)
-		}
-	}
-	topics = topics[:i]
+	validTopics, invalidTopics := models.SanitizeAndValidateTopics(topics)
 
-	if len(topics) > 25 {
+	if len(validTopics) > 25 {
 		ctx.JSON(422, map[string]interface{}{
-			"invalidTopics": topics[:0],
+			"invalidTopics": nil,
 			"message":       ctx.Tr("repo.topic.count_prompt"),
 		})
 		return
@@ -58,7 +45,7 @@ func TopicsPost(ctx *context.Context) {
 		return
 	}
 
-	err := models.SaveTopics(ctx.Repo.Repository.ID, topics...)
+	err := models.SaveTopics(ctx.Repo.Repository.ID, validTopics...)
 	if err != nil {
 		log.Error("SaveTopics failed: %v", err)
 		ctx.JSON(500, map[string]interface{}{
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index eed60c044c..8cf22251a6 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -5451,6 +5451,155 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/topics": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get list of topics that a repository has",
+        "operationId": "repoListTopics",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/TopicNames"
+          }
+        }
+      },
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Replace list of topics for a repository",
+        "operationId": "repoUpdateTopics",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/RepoTopicOptions"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/topics/{topic}": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Add a topic to a repository",
+        "operationId": "repoAddTopĆ­c",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the topic to add",
+            "name": "topic",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Delete a topic from a repository",
+        "operationId": "repoDeleteTopic",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the topic to delete",
+            "name": "topic",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      }
+    },
     "/repositories/{id}": {
       "get": {
         "produces": [
@@ -5815,7 +5964,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/Repository"
+            "$ref": "#/responses/TopicListResponse"
           }
         }
       }
@@ -9561,6 +9710,21 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "RepoTopicOptions": {
+      "description": "RepoTopicOptions a collection of repo topic names",
+      "type": "object",
+      "properties": {
+        "topics": {
+          "description": "list of topic names",
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "x-go-name": "Topics"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Repository": {
       "description": "Repository represents a repository",
       "type": "object",
@@ -9874,6 +10038,51 @@
       "format": "int64",
       "x-go-package": "code.gitea.io/gitea/modules/timeutil"
     },
+    "TopicName": {
+      "description": "TopicName a list of repo topic names",
+      "type": "object",
+      "properties": {
+        "topics": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "x-go-name": "TopicNames"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "TopicResponse": {
+      "description": "TopicResponse for returning topics",
+      "type": "object",
+      "properties": {
+        "created": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "Created"
+        },
+        "id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ID"
+        },
+        "repo_count": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RepoCount"
+        },
+        "topic_name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "updated": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "Updated"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "TrackedTime": {
       "description": "TrackedTime worked time for an issue / pr",
       "type": "object",
@@ -10493,6 +10702,21 @@
         }
       }
     },
+    "TopicListResponse": {
+      "description": "TopicListResponse",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/TopicResponse"
+        }
+      }
+    },
+    "TopicNames": {
+      "description": "TopicNames",
+      "schema": {
+        "$ref": "#/definitions/TopicName"
+      }
+    },
     "TrackedTime": {
       "description": "TrackedTime",
       "schema": {
@@ -10569,7 +10793,7 @@
     "parameterBodies": {
       "description": "parameterBodies",
       "schema": {
-        "$ref": "#/definitions/DeleteFileOptions"
+        "$ref": "#/definitions/RepoTopicOptions"
       }
     },
     "redirect": {