diff --git a/models/issue.go b/models/issue.go
index 324f4eaa8..58649c754 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -1219,6 +1219,8 @@ func sortIssuesSession(sess *xorm.Session, sortType string, priorityRepoID int64
 				"ELSE issue.deadline_unix END DESC")
 	case "priorityrepo":
 		sess.OrderBy("CASE WHEN issue.repo_id = " + strconv.FormatInt(priorityRepoID, 10) + " THEN 1 ELSE 2 END, issue.created_unix DESC")
+	case "project-column-sorting":
+		sess.Asc("project_issue.sorting")
 	default:
 		sess.Desc("issue.created_unix")
 	}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 6b7caba89..a5bacd0d9 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -359,6 +359,8 @@ var migrations = []Migration{
 	NewMigration("Drop table remote_version (if exists)", dropTableRemoteVersion),
 	// v202 -> v203
 	NewMigration("Create key/value table for user settings", createUserSettingsTable),
+	// v203 -> v204
+	NewMigration("Add Sorting to ProjectIssue table", addProjectIssueSorting),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v203.go b/models/migrations/v203.go
new file mode 100644
index 000000000..2e1dd7289
--- /dev/null
+++ b/models/migrations/v203.go
@@ -0,0 +1,18 @@
+// Copyright 2021 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 migrations
+
+import (
+	"xorm.io/xorm"
+)
+
+func addProjectIssueSorting(x *xorm.Engine) error {
+	// ProjectIssue saves relation from issue to a project
+	type ProjectIssue struct {
+		Sorting int64 `xorm:"NOT NULL DEFAULT 0"`
+	}
+
+	return x.Sync2(new(ProjectIssue))
+}
diff --git a/models/project_board.go b/models/project_board.go
index 2d422a203..d40cfd06f 100644
--- a/models/project_board.go
+++ b/models/project_board.go
@@ -265,6 +265,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) {
 		issues, err := Issues(&IssuesOptions{
 			ProjectBoardID: b.ID,
 			ProjectID:      b.ProjectID,
+			SortType:       "project-column-sorting",
 		})
 		if err != nil {
 			return nil, err
@@ -276,6 +277,7 @@ func (b *ProjectBoard) LoadIssues() (IssueList, error) {
 		issues, err := Issues(&IssuesOptions{
 			ProjectBoardID: -1, // Issues without ProjectBoardID
 			ProjectID:      b.ProjectID,
+			SortType:       "project-column-sorting",
 		})
 		if err != nil {
 			return nil, err
diff --git a/models/project_issue.go b/models/project_issue.go
index fb08efa99..c1421485b 100644
--- a/models/project_issue.go
+++ b/models/project_issue.go
@@ -20,6 +20,7 @@ type ProjectIssue struct {
 
 	// If 0, then it has not been added to a specific board in the project
 	ProjectBoardID int64 `xorm:"INDEX"`
+	Sorting        int64 `xorm:"NOT NULL DEFAULT 0"`
 }
 
 func init() {
@@ -184,34 +185,34 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U
 // |_|   |_|  \___// |\___|\___|\__|____/ \___/ \__,_|_|  \__,_|
 //               |__/
 
-// MoveIssueAcrossProjectBoards move a card from one board to another
-func MoveIssueAcrossProjectBoards(issue *Issue, board *ProjectBoard) error {
-	ctx, committer, err := db.TxContext()
-	if err != nil {
-		return err
-	}
-	defer committer.Close()
-	sess := db.GetEngine(ctx)
+// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column
+func MoveIssuesOnProjectBoard(board *ProjectBoard, sortedIssueIDs map[int64]int64) error {
+	return db.WithTx(func(ctx context.Context) error {
+		sess := db.GetEngine(ctx)
 
-	var pis ProjectIssue
-	has, err := sess.Where("issue_id=?", issue.ID).Get(&pis)
-	if err != nil {
-		return err
-	}
+		issueIDs := make([]int64, 0, len(sortedIssueIDs))
+		for _, issueID := range sortedIssueIDs {
+			issueIDs = append(issueIDs, issueID)
+		}
+		count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
+		if err != nil {
+			return err
+		}
+		if int(count) != len(sortedIssueIDs) {
+			return fmt.Errorf("all issues have to be added to a project first")
+		}
 
-	if !has {
-		return fmt.Errorf("issue has to be added to a project first")
-	}
-
-	pis.ProjectBoardID = board.ID
-	if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil {
-		return err
-	}
-
-	return committer.Commit()
+		for sorting, issueID := range sortedIssueIDs {
+			_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
 }
 
 func (pb *ProjectBoard) removeIssues(e db.Engine) error {
-	_, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", pb.ID)
+	_, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0, sorting = 0 WHERE project_board_id = ? ", pb.ID)
 	return err
 }
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index c23754438..a8b2a7a5c 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -5,6 +5,7 @@
 package repo
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -299,7 +300,6 @@ func ViewProject(ctx *context.Context) {
 		ctx.ServerError("LoadIssuesOfBoards", err)
 		return
 	}
-	ctx.Data["Issues"] = issueList
 
 	linkedPrsMap := make(map[int64][]*models.Issue)
 	for _, issue := range issueList {
@@ -547,9 +547,8 @@ func SetDefaultProjectBoard(ctx *context.Context) {
 	})
 }
 
-// MoveIssueAcrossBoards move a card from one board to another in a project
-func MoveIssueAcrossBoards(ctx *context.Context) {
-
+// MoveIssues moves or keeps issues in a column and sorts them inside that column
+func MoveIssues(ctx *context.Context) {
 	if ctx.User == nil {
 		ctx.JSON(http.StatusForbidden, map[string]string{
 			"message": "Only signed in users are allowed to perform this action.",
@@ -564,59 +563,80 @@ func MoveIssueAcrossBoards(ctx *context.Context) {
 		return
 	}
 
-	p, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
 		if models.IsErrProjectNotExist(err) {
-			ctx.NotFound("", nil)
+			ctx.NotFound("ProjectNotExist", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
 		}
 		return
 	}
-	if p.RepoID != ctx.Repo.Repository.ID {
-		ctx.NotFound("", nil)
+	if project.RepoID != ctx.Repo.Repository.ID {
+		ctx.NotFound("InvalidRepoID", nil)
 		return
 	}
 
 	var board *models.ProjectBoard
 
 	if ctx.ParamsInt64(":boardID") == 0 {
-
 		board = &models.ProjectBoard{
 			ID:        0,
-			ProjectID: 0,
+			ProjectID: project.ID,
 			Title:     ctx.Tr("repo.projects.type.uncategorized"),
 		}
-
 	} else {
+		// column
 		board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
 		if err != nil {
 			if models.IsErrProjectBoardNotExist(err) {
-				ctx.NotFound("", nil)
+				ctx.NotFound("ProjectBoardNotExist", nil)
 			} else {
 				ctx.ServerError("GetProjectBoard", err)
 			}
 			return
 		}
-		if board.ProjectID != p.ID {
-			ctx.NotFound("", nil)
+		if board.ProjectID != project.ID {
+			ctx.NotFound("BoardNotInProject", nil)
 			return
 		}
 	}
 
-	issue, err := models.GetIssueByID(ctx.ParamsInt64(":index"))
+	type movedIssuesForm struct {
+		Issues []struct {
+			IssueID int64 `json:"issueID"`
+			Sorting int64 `json:"sorting"`
+		} `json:"issues"`
+	}
+
+	form := &movedIssuesForm{}
+	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("DecodeMovedIssuesForm", err)
+	}
+
+	issueIDs := make([]int64, 0, len(form.Issues))
+	sortedIssueIDs := make(map[int64]int64)
+	for _, issue := range form.Issues {
+		issueIDs = append(issueIDs, issue.IssueID)
+		sortedIssueIDs[issue.Sorting] = issue.IssueID
+	}
+	movedIssues, err := models.GetIssuesByIDs(issueIDs)
 	if err != nil {
 		if models.IsErrIssueNotExist(err) {
-			ctx.NotFound("", nil)
+			ctx.NotFound("IssueNotExisting", nil)
 		} else {
 			ctx.ServerError("GetIssueByID", err)
 		}
-
 		return
 	}
 
-	if err := models.MoveIssueAcrossProjectBoards(issue, board); err != nil {
-		ctx.ServerError("MoveIssueAcrossProjectBoards", err)
+	if len(movedIssues) != len(form.Issues) {
+		ctx.ServerError("IssuesNotFound", err)
+		return
+	}
+
+	if err = models.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
+		ctx.ServerError("MoveIssuesOnProjectBoard", err)
 		return
 	}
 
diff --git a/routers/web/web.go b/routers/web/web.go
index c52d3483f..0d4d3bd90 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -897,7 +897,7 @@ func RegisterRoutes(m *web.Route) {
 						m.Delete("", repo.DeleteProjectBoard)
 						m.Post("/default", repo.SetDefaultProjectBoard)
 
-						m.Post("/{index}", repo.MoveIssueAcrossBoards)
+						m.Post("/move", repo.MoveIssues)
 					})
 				})
 			}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
index 986ada295..5b3f54f8a 100644
--- a/web_src/js/features/repo-projects.js
+++ b/web_src/js/features/repo-projects.js
@@ -1,5 +1,29 @@
 const {csrfToken} = window.config;
 
+function moveIssue({item, from, to, oldIndex}) {
+  const columnCards = to.getElementsByClassName('board-card');
+
+  const columnSorting = {
+    issues: [...columnCards].map((card, i) => ({
+      issueID: parseInt($(card).attr('data-issue')),
+      sorting: i
+    }))
+  };
+
+  $.ajax({
+    url: `${to.getAttribute('data-url')}/move`,
+    data: JSON.stringify(columnSorting),
+    headers: {
+      'X-Csrf-Token': csrfToken,
+    },
+    contentType: 'application/json',
+    type: 'POST',
+    error: () => {
+      from.insertBefore(item, from.children[oldIndex]);
+    }
+  });
+}
+
 async function initRepoProjectSortable() {
   const els = document.querySelectorAll('#project-board > .board');
   if (!els.length) return;
@@ -40,20 +64,8 @@ async function initRepoProjectSortable() {
       group: 'shared',
       animation: 150,
       ghostClass: 'card-ghost',
-      onAdd: ({item, from, to, oldIndex}) => {
-        const url = to.getAttribute('data-url');
-        const issue = item.getAttribute('data-issue');
-        $.ajax(`${url}/${issue}`, {
-          headers: {
-            'X-Csrf-Token': csrfToken,
-          },
-          contentType: 'application/json',
-          type: 'POST',
-          error: () => {
-            from.insertBefore(item, from.children[oldIndex]);
-          },
-        });
-      },
+      onAdd: moveIssue,
+      onUpdate: moveIssue,
     });
   }
 }