diff --git a/doc/screenshot.png b/doc/screenshot.png
index 1c1f339630b048780aed1e692290dada8aa23307..5fbf46bb4361354d78c186089dd3301525cdb614 100644
Binary files a/doc/screenshot.png and b/doc/screenshot.png differ
diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js
index 4f0649c427e32e1fa65ec2dff6d23ff85c7e489d..1cd9e0e6a1d206068959c729d79cdcbdaeafd70c 100644
--- a/services/web/app/src/Features/Project/ProjectEditorHandler.js
+++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js
@@ -8,7 +8,7 @@ function mergeDeletedDocs(a, b) {
 }
 
 module.exports = ProjectEditorHandler = {
-  trackChangesAvailable: false,
+  trackChangesAvailable: true,
 
   buildProjectModelView(project, members, invites, deletedDocsFromDocstore) {
     let owner, ownerFeatures
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index f1bdbc29e35723440133834368a51f932d44e3ed..f4b6fade0d733586c51521b4756ca081ae8ca2fe 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -907,6 +907,7 @@ module.exports = {
     'launchpad',
     'server-ce-scripts',
     'user-activate',
+    'track-changes',
   ],
   viewIncludes: {},
 
diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
index 1bf99d62ce43af4e18b9f4e12b5f6fb5b58d319c..2767b81562d7a4f24bbf958a0301343737f12bb5 100644
--- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
+++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
@@ -84,6 +84,7 @@ const formatUser = (user: any): any => {
 
   if (id == null) {
     return {
+      id: 'anonymous-user',
       email: null,
       name: 'Anonymous',
       isSelf: false,
@@ -283,15 +284,19 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
           // Always include ourself, since if we submit an op, we might need to display info
           // about it locally before it has been flushed through the server
           if (user) {
-            tempUsers[user.id] = formatUser(user)
+            if (user.id) {
+              tempUsers[user.id] = formatUser(user)
+            } else {
+              tempUsers['anonymous-user'] = formatUser(user)
+            }
           }
-
           for (const user of usersResponse) {
             if (user.id) {
               tempUsers[user.id] = formatUser(user)
+            } else {
+              tempUsers['anonymous-user'] = formatUser(user)
             }
           }
-
           setUsers(tempUsers)
         })
         .catch(error => {
@@ -532,9 +537,9 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
     }
   }, [currentDocument, regenerateTrackChangesId, resolvedThreadIds])
 
-  const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous' => {
+  const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous-user' => {
     if (!user) {
-      return 'anonymous'
+      return 'anonymous-user'
     }
     if (project.owner._id === user.id) {
       return 'member'
@@ -587,7 +592,7 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
   const setGuestsTCState = useCallback(
     (newValue: boolean) => {
       setTrackChangesOnForGuests(newValue)
-      if (currentUserType() === 'guest' || currentUserType() === 'anonymous') {
+      if (currentUserType() === 'guest' || currentUserType() === 'anonymous-user') {
         setWantTrackChanges(newValue)
       }
     },
diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
index 853633916cccc2ae7fe983dcfb261ded35550571..689847c9374a64326ccb68d04fd035845627d804 100644
--- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
+++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
@@ -177,7 +177,7 @@ function useCodeMirrorScope(view: EditorView) {
 
     if (currentDoc) {
       if (trackChanges) {
-        currentDoc.track_changes_as = userId || 'anonymous'
+        currentDoc.track_changes_as = userId || 'anonymous-user'
       } else {
         currentDoc.track_changes_as = null
       }
diff --git a/services/web/modules/track-changes/app/src/TrackChangesController.js b/services/web/modules/track-changes/app/src/TrackChangesController.js
new file mode 100644
index 0000000000000000000000000000000000000000..6cf3645c567e6c979cd519cd0314b9bbfc60f3e2
--- /dev/null
+++ b/services/web/modules/track-changes/app/src/TrackChangesController.js
@@ -0,0 +1,308 @@
+const ChatApiHandler = require('../../../../app/src/Features/Chat/ChatApiHandler')
+const ChatManager = require('../../../../app/src/Features/Chat/ChatManager')
+const EditorRealTimeController = require('../../../../app/src/Features/Editor/EditorRealTimeController')
+const SessionManager = require('../../../../app/src/Features/Authentication/SessionManager')
+const UserInfoManager = require('../../../../app/src/Features/User/UserInfoManager')
+const DocstoreManager = require('../../../../app/src/Features/Docstore/DocstoreManager')
+const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler')
+const CollaboratorsGetter = require('../../../../app/src/Features/Collaborators/CollaboratorsGetter')
+const { Project } = require('../../../../app/src/models/Project')
+const pLimit = require('p-limit')
+
+async function _updateTCState (projectId, state, callback) {
+  await Project.updateOne({_id: projectId}, {track_changes: state}).exec()
+  callback()
+}
+function _transformId(doc) {
+  if (doc._id) {
+    doc.id = doc._id;
+    delete doc._id;
+  }
+  return doc;
+}
+
+const TrackChangesController = {
+  trackChanges(req, res, next) {
+    const { project_id } = req.params
+    let state = req.body.on || req.body.on_for
+    if ( req.body.on_for_guests && !req.body.on ) state.__guests__ = true
+
+    return _updateTCState(project_id, state,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        EditorRealTimeController.emitToRoom(
+          project_id,
+          'toggle-track-changes',
+          state
+        )
+        return res.sendStatus(204)
+      }
+    )
+  },
+  acceptChanges(req, res, next) {
+    const { project_id, doc_id } = req.params
+    const change_ids = req.body.change_ids
+    return DocumentUpdaterHandler.acceptChanges(
+      project_id,
+      doc_id,
+      change_ids,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        EditorRealTimeController.emitToRoom(
+          project_id,
+          'accept-changes',
+          doc_id,
+          change_ids,
+        )
+        return res.sendStatus(204)
+      }
+    )
+  },
+  async getAllRanges(req, res, next) {
+    const { project_id } = req.params
+    // FIXME: ranges are from mongodb, probably already outdated
+    const ranges = await DocstoreManager.promises.getAllRanges(project_id)
+// frontend expects 'id', not '_id'
+    return res.json(ranges.map(_transformId))
+  },
+  async getChangesUsers(req, res, next) {
+    const { project_id } = req.params
+    const memberIds = await CollaboratorsGetter.promises.getMemberIds(project_id)
+    // FIXME: Does not work properly if the user is no longer a member of the project
+    // memberIds from DocstoreManager.getAllRanges(project_id) is not a remedy
+    // because ranges are not updated in real-time
+    const limit = pLimit(3)
+    const users = await Promise.all(
+      memberIds.map(memberId =>
+        limit(async () => {
+          const user = await UserInfoManager.promises.getPersonalInfo(memberId)
+          return user
+        })
+      )
+    )
+    users.push({_id: null}) // An anonymous user won't cause any harm
+// frontend expects 'id', not '_id'
+    return res.json(users.map(_transformId))
+  },
+  getThreads(req, res, next) {
+    const { project_id } = req.params
+    return ChatApiHandler.getThreads(
+      project_id,
+      function (err, messages) {
+        if (err != null) {
+          return next(err)
+        }
+        return ChatManager.injectUserInfoIntoThreads(
+          messages,
+          function (err) {
+            if (err != null) {
+              return next(err)
+            }
+            return res.json(messages)
+          }
+        )
+      }
+    )
+  },
+  sendComment(req, res, next) {
+    const { project_id, thread_id } = req.params
+    const { content } = req.body
+    const user_id = SessionManager.getLoggedInUserId(req.session)
+    if (user_id == null) {
+      const err = new Error('no logged-in user')
+      return next(err)
+    }
+    return ChatApiHandler.sendComment(
+      project_id,
+      thread_id,
+      user_id,
+      content,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        return UserInfoManager.getPersonalInfo(
+          user_id,
+          function (err, user) {
+            if (err != null) {
+              return next(err)
+            }
+            message.user = user
+            EditorRealTimeController.emitToRoom(
+              project_id,
+              'new-comment',
+              thread_id, message
+            )
+            return res.sendStatus(204)
+          }
+        )
+      }
+    )
+  },
+  editMessage(req, res, next) {
+    const { project_id, thread_id, message_id } = req.params
+    const { content } = req.body
+    const user_id = SessionManager.getLoggedInUserId(req.session)
+    if (user_id == null) {
+      const err = new Error('no logged-in user')
+      return next(err)
+    }
+    return ChatApiHandler.editMessage(
+      project_id,
+      thread_id,
+      message_id,
+      user_id,
+      content,
+      function (err, message) {
+        if (err != null) {
+            return next(err)
+        }
+        EditorRealTimeController.emitToRoom(
+          project_id,
+          'edit-message',
+          thread_id,
+          message_id,
+          content
+        )
+        return res.sendStatus(204)
+      }
+    )
+  },
+  deleteMessage(req, res, next) {
+    const { project_id, thread_id, message_id } = req.params
+    return ChatApiHandler.deleteMessage(
+      project_id,
+      thread_id,
+      message_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        EditorRealTimeController.emitToRoom(
+          project_id,
+          'delete-message',
+          thread_id,
+          message_id
+        )
+        return res.sendStatus(204)
+      }
+    )
+  },
+  resolveThread(req, res, next) {
+    const { project_id, doc_id, thread_id } = req.params
+    const user_id = SessionManager.getLoggedInUserId(req.session)
+    if (user_id == null) {
+      const err = new Error('no logged-in user')
+      return next(err)
+    }
+    DocumentUpdaterHandler.resolveThread(
+      project_id,
+      doc_id,
+      thread_id,
+      user_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+      }
+    )
+    return ChatApiHandler.resolveThread(
+      project_id,
+      thread_id,
+      user_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        return UserInfoManager.getPersonalInfo(
+          user_id,
+          function (err, user) {
+            if (err != null) {
+              return next(err)
+            }
+            EditorRealTimeController.emitToRoom(
+              project_id,
+              'resolve-thread',
+              thread_id,
+              user
+            )
+            return res.sendStatus(204)
+          }
+        )
+      }
+    )
+  },
+  reopenThread(req, res, next) {
+    const { project_id, doc_id, thread_id } = req.params
+    const user_id = SessionManager.getLoggedInUserId(req.session)
+    if (user_id == null) {
+      const err = new Error('no logged-in user')
+      return next(err)
+    }
+    DocumentUpdaterHandler.reopenThread(
+      project_id,
+      doc_id,
+      thread_id,
+      user_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+      }
+    )
+    return ChatApiHandler.reopenThread(
+      project_id,
+      thread_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        EditorRealTimeController.emitToRoom(
+          project_id,
+          'reopen-thread',
+          thread_id
+        )
+        return res.sendStatus(204)
+      }
+    )
+  },
+  deleteThread(req, res, next) {
+    const { project_id, doc_id, thread_id } = req.params
+    const user_id = SessionManager.getLoggedInUserId(req.session)
+    if (user_id == null) {
+      const err = new Error('no logged-in user')
+      return next(err)
+    }
+    return DocumentUpdaterHandler.deleteThread(
+      project_id,
+      doc_id,
+      thread_id,
+      user_id,
+      function (err, message) {
+        if (err != null) {
+          return next(err)
+        }
+        ChatApiHandler.deleteThread(
+          project_id,
+          thread_id,
+          function (err, message) {
+            if (err != null) {
+              return next(err)
+            }
+            EditorRealTimeController.emitToRoom(
+              project_id,
+              'delete-thread',
+              thread_id
+            )
+            return res.sendStatus(204)
+          }
+        )
+      }
+    )
+  },
+}
+module.exports = TrackChangesController
diff --git a/services/web/modules/track-changes/app/src/TrackChangesRouter.js b/services/web/modules/track-changes/app/src/TrackChangesRouter.js
new file mode 100644
index 0000000000000000000000000000000000000000..3791e251a1a3527d545d2cf482e8d6a2ee14c8f2
--- /dev/null
+++ b/services/web/modules/track-changes/app/src/TrackChangesRouter.js
@@ -0,0 +1,72 @@
+const logger = require('@overleaf/logger')
+const AuthorizationMiddleware = require('../../../../app/src/Features/Authorization/AuthorizationMiddleware')
+const TrackChangesController = require('./TrackChangesController')
+
+module.exports = {
+  apply(webRouter) {
+    logger.debug({}, 'Init track-changes router')
+
+    webRouter.post('/project/:project_id/track_changes',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.trackChanges
+    )
+    webRouter.post('/project/:project_id/doc/:doc_id/changes/accept',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.acceptChanges
+    )
+    webRouter.get('/project/:project_id/ranges',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.getAllRanges
+    )
+    webRouter.get('/project/:project_id/changes/users',
+     AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.getChangesUsers
+    )
+    webRouter.get(
+      '/project/:project_id/threads',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.getThreads
+    )
+    webRouter.post(
+      '/project/:project_id/thread/:thread_id/messages',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.sendComment
+    )
+    webRouter.post(
+      '/project/:project_id/thread/:thread_id/messages/:message_id/edit',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.editMessage
+    )
+    webRouter.delete(
+      '/project/:project_id/thread/:thread_id/messages/:message_id',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.deleteMessage
+    )
+    webRouter.post(
+      '/project/:project_id/doc/:doc_id/thread/:thread_id/resolve',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.resolveThread
+    )
+    webRouter.post(
+      '/project/:project_id/doc/:doc_id/thread/:thread_id/reopen',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.reopenThread
+    )
+    webRouter.delete(
+      '/project/:project_id/doc/:doc_id/thread/:thread_id',
+      AuthorizationMiddleware.blockRestrictedUserFromProject,
+      AuthorizationMiddleware.ensureUserCanReadProject,
+      TrackChangesController.deleteThread
+    )
+  },
+}
diff --git a/services/web/modules/track-changes/index.js b/services/web/modules/track-changes/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa9e6a73dad1ae15d5150ec89d486f55527fb062
--- /dev/null
+++ b/services/web/modules/track-changes/index.js
@@ -0,0 +1,2 @@
+const TrackChangesRouter = require('./app/src/TrackChangesRouter')
+module.exports = { router : TrackChangesRouter }