diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md
index 815bd45734c2a9d404c61017aac7b3b113b2a1a2..d496ecbca5b106e57ab1c4a891447ef785f45720 100644
--- a/doc/api/api_resources.md
+++ b/doc/api/api_resources.md
@@ -171,6 +171,7 @@ The following API resources are available outside of project and group contexts
 | [Suggestions](suggestions.md)                      | `/suggestions`                                                          |
 | [System hooks](system_hooks.md)                    | `/hooks`                                                                |
 | [To-dos](todos.md)                                 | `/todos`                                                                |
+| [Topics](topics.md)                                | `/topics`                                                               |
 | [Service Data](usage_data.md)                        | `/usage_data` (For GitLab instance [Administrator](../user/permissions.md) users only) |
 | [Users](users.md)                                  | `/users`                                                                |
 | [Validate `.gitlab-ci.yml` file](lint.md)          | `/lint`                                                                 |
diff --git a/doc/api/topics.md b/doc/api/topics.md
new file mode 100644
index 0000000000000000000000000000000000000000..5e9e1b8fc1298c4129a6f3ab7e17473ef87890e8
--- /dev/null
+++ b/doc/api/topics.md
@@ -0,0 +1,190 @@
+---
+stage: Manage
+group: Workspace
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Topics API **(FREE)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340920) in GitLab 14.5.
+
+Interact with project topics using the REST API.
+
+## List topics
+
+Returns a list of project topics in the GitLab instance ordered by number of associated projects.
+
+```plaintext
+GET /topics
+```
+
+Supported attributes:
+
+| Attribute  | Type    | Required               | Description |
+| ---------- | ------- | ---------------------- | ----------- |
+| `page`     | integer | **{dotted-circle}** No | Page to retrieve. Defaults to `1`.                      |
+| `per_page` | integer | **{dotted-circle}** No | Number of records to return per page. Defaults to `20`. |
+| `search`   | string  | **{dotted-circle}** No | Search topics against their `name`.                     |
+
+Example request:
+
+```shell
+curl "https://gitlab.example.com/api/v4/topics?search=git"
+```
+
+Example response:
+
+```json
+[
+  {
+    "id": 1,
+    "name": "GitLab",
+    "description": "GitLab is an open source end-to-end software development platform with built-in version control, issue tracking, code review, CI/CD, and more.",
+    "total_projects_count": 1000,
+    "avatar_url": "http://www.gravatar.com/avatar/a0d477b3ea21970ce6ffcbb817b0b435?s=80&d=identicon"
+  },
+  {
+    "id": 3,
+    "name": "Git",
+    "description": "Git is a free and open source distributed version control system designed to handle everything from small to very large projects with speed and efficiency.",
+    "total_projects_count": 900,
+    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
+  },
+  {
+    "id": 2,
+    "name": "Git LFS",
+    "description": null,
+    "total_projects_count": 300,
+    "avatar_url": null
+  }
+]
+```
+
+## Get a topic
+
+Get a project topic by ID.
+
+```plaintext
+GET /topics/:id
+```
+
+Supported attributes:
+
+| Attribute | Type    | Required               | Description         |
+| --------- | ------- | ---------------------- | ------------------- |
+| `id`      | integer | **{check-circle}** Yes | ID of project topic |
+
+Example request:
+
+```shell
+curl "https://gitlab.example.com/api/v4/topics/1"
+```
+
+Example response:
+
+```json
+{
+  "id": 1,
+  "name": "GitLab",
+  "description": "GitLab is an open source end-to-end software development platform with built-in version control, issue tracking, code review, CI/CD, and more.",
+  "total_projects_count": 1000,
+  "avatar_url": "http://www.gravatar.com/avatar/a0d477b3ea21970ce6ffcbb817b0b435?s=80&d=identicon"
+}
+```
+
+## List projects assigned to a topic
+
+Use the [Projects API](projects.md#list-all-projects) to list all projects assigned to a specific topic.
+
+```plaintext
+GET /projects?topic=<topic_name>
+```
+
+## Create a project topic
+
+Create a new project topic. Only available to administrators.
+
+```plaintext
+POST /topics
+```
+
+Supported attributes:
+
+| Attribute     | Type    | Required               | Description |
+| ------------- | ------- | ---------------------- | ----------- |
+| `name`        | string  | **{check-circle}** Yes | Name        |
+| `avatar`      | file    | **{dotted-circle}** No | Avatar      |
+| `description` | string  | **{dotted-circle}** No | Description |
+
+Example request:
+
+```shell
+curl --request POST \
+     --data "name=topic1" \
+     --header "PRIVATE-TOKEN: <your_access_token>" \
+     "https://gitlab.example.com/api/v4/topics"
+```
+
+Example response:
+
+```json
+{
+  "id": 1,
+  "name": "topic1",
+  "description": null,
+  "total_projects_count": 0,
+  "avatar_url": null
+}
+```
+
+## Update a project topic
+
+Update a project topic. Only available to administrators.
+
+```plaintext
+PUT /topics/:id
+```
+
+Supported attributes:
+
+| Attribute     | Type    | Required               | Description         |
+| ------------- | ------- | ---------------------- | ------------------- |
+| `id`          | integer | **{check-circle}** Yes | ID of project topic |
+| `avatar`      | file    | **{dotted-circle}** No | Avatar              |
+| `description` | string  | **{dotted-circle}** No | Description         |
+| `name`        | string  | **{dotted-circle}** No | Name                |
+
+Example request:
+
+```shell
+curl --request PUT \
+     --data "name=topic1" \
+     --header "PRIVATE-TOKEN: <your_access_token>" \
+     "https://gitlab.example.com/api/v4/topics/1"
+
+
+Example response:
+
+```json
+{
+  "id": 1,
+  "name": "topic1",
+  "description": null,
+  "total_projects_count": 0,
+  "avatar_url": null
+}
+```
+
+### Upload a topic avatar
+
+To upload an avatar file from your file system, use the `--form` argument. This argument causes
+cURL to post data using the header `Content-Type: multipart/form-data`. The
+`file=` parameter must point to a file on your file system and be preceded by
+`@`. For example:
+
+```shell
+curl --request PUT \
+     --header "PRIVATE-TOKEN: <your_access_token>" \
+     "https://gitlab.example.com/api/v4/topics/1" \
+     --form "avatar=@/tmp/example.png"
+```
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 0d5cf2792af7cf833ade3512314b66fb5d475121..dcecaeae558eb43f59b21bf34450f56a4763df96 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -284,6 +284,7 @@ class API < ::API::Base
       mount ::API::Tags
       mount ::API::Templates
       mount ::API::Todos
+      mount ::API::Topics
       mount ::API::Unleash
       mount ::API::UsageData
       mount ::API::UsageDataQueries
diff --git a/lib/api/entities/projects/topic.rb b/lib/api/entities/projects/topic.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d3d1cbec81ce73dba67076c711c0e866b4dc8be3
--- /dev/null
+++ b/lib/api/entities/projects/topic.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module API
+  module Entities
+    module Projects
+      class Topic < Grape::Entity
+        expose :id
+        expose :name
+        expose :description
+        expose :total_projects_count
+        expose :avatar_url do |topic, options|
+          topic.avatar_url(only_path: false)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/api/topics.rb b/lib/api/topics.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bd28ebe58a9313a8cf0109820747603c7c8cbf10
--- /dev/null
+++ b/lib/api/topics.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module API
+  class Topics < ::API::Base
+    include PaginationParams
+
+    feature_category :projects
+
+    desc 'Get topics' do
+      detail 'This feature was introduced in GitLab 14.5.'
+      success Entities::Projects::Topic
+    end
+    params do
+      optional :search, type: String, desc: 'Return list of topics matching the search criteria'
+      use :pagination
+    end
+    get 'topics' do
+      topics = ::Projects::TopicsFinder.new(params: declared_params(include_missing: false)).execute
+
+      present paginate(topics), with: Entities::Projects::Topic
+    end
+
+    desc 'Get topic' do
+      detail 'This feature was introduced in GitLab 14.5.'
+      success Entities::Projects::Topic
+    end
+    params do
+      requires :id, type: Integer, desc: 'ID of project topic'
+    end
+    get 'topics/:id' do
+      topic = ::Projects::Topic.find(params[:id])
+
+      present topic, with: Entities::Projects::Topic
+    end
+
+    desc 'Create a topic' do
+      detail 'This feature was introduced in GitLab 14.5.'
+      success Entities::Projects::Topic
+    end
+    params do
+      requires :name, type: String, desc: 'Name'
+      optional :description, type: String, desc: 'Description'
+      optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for topic'
+    end
+    post 'topics' do
+      authenticated_as_admin!
+
+      topic = ::Projects::Topic.new(declared_params(include_missing: false))
+
+      if topic.save
+        present topic, with: Entities::Projects::Topic
+      else
+        render_validation_error!(topic)
+      end
+    end
+
+    desc 'Update a topic' do
+      detail 'This feature was introduced in GitLab 14.5.'
+      success Entities::Projects::Topic
+    end
+    params do
+      requires :id, type: Integer, desc: 'ID of project topic'
+      optional :name, type: String, desc: 'Name'
+      optional :description, type: String, desc: 'Description'
+      optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for topic'
+    end
+    put 'topics/:id' do
+      authenticated_as_admin!
+
+      topic = ::Projects::Topic.find(params[:id])
+
+      if topic.update(declared_params(include_missing: false))
+        present topic, with: Entities::Projects::Topic
+      else
+        render_validation_error!(topic)
+      end
+    end
+  end
+end
diff --git a/spec/lib/api/entities/projects/topic_spec.rb b/spec/lib/api/entities/projects/topic_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cdf142dbb7d6614a34bda55a0091ec7b4fd15217
--- /dev/null
+++ b/spec/lib/api/entities/projects/topic_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Projects::Topic do
+  let(:topic) { create(:topic) }
+
+  subject { described_class.new(topic).as_json }
+
+  it 'exposes correct attributes' do
+    expect(subject).to include(
+      :id,
+      :name,
+      :description,
+      :total_projects_count,
+      :avatar_url
+    )
+  end
+end
diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a5746a4022e76849d472e65bb588f7b31555b3c0
--- /dev/null
+++ b/spec/requests/api/topics_spec.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Topics do
+  include WorkhorseHelpers
+
+  let_it_be(:topic_1) { create(:topic, name: 'Git', total_projects_count: 1) }
+  let_it_be(:topic_2) { create(:topic, name: 'GitLab', total_projects_count: 2) }
+  let_it_be(:topic_3) { create(:topic, name: 'other-topic', total_projects_count: 3) }
+
+  let_it_be(:admin) { create(:user, :admin) }
+  let_it_be(:user) { create(:user) }
+
+  let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
+
+  describe 'GET /topics', :aggregate_failures do
+    it 'returns topics ordered by total_projects_count' do
+      get api('/topics')
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to include_pagination_headers
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(3)
+
+      expect(json_response[0]['id']).to eq(topic_3.id)
+      expect(json_response[0]['name']).to eq('other-topic')
+      expect(json_response[0]['total_projects_count']).to eq(3)
+
+      expect(json_response[1]['id']).to eq(topic_2.id)
+      expect(json_response[1]['name']).to eq('GitLab')
+      expect(json_response[1]['total_projects_count']).to eq(2)
+
+      expect(json_response[2]['id']).to eq(topic_1.id)
+      expect(json_response[2]['name']).to eq('Git')
+      expect(json_response[2]['total_projects_count']).to eq(1)
+    end
+
+    context 'with search' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:search, :result) do
+        ''    | %w[other-topic GitLab Git]
+        'g'   | %w[]
+        'gi'  | %w[]
+        'git' | %w[Git GitLab]
+        'x'   | %w[]
+        0     | %w[]
+      end
+
+      with_them do
+        it 'returns filtered topics' do
+          get api('/topics'), params: { search: search }
+
+          expect(json_response.map { |t| t['name'] }).to eq(result)
+        end
+      end
+    end
+
+    context 'with pagination' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:params, :result) do
+        { page: 0 }              | %w[other-topic GitLab Git]
+        { page: 1 }              | %w[other-topic GitLab Git]
+        { page: 2 }              | %w[]
+        { per_page: 1 }          | %w[other-topic]
+        { per_page: 2 }          | %w[other-topic GitLab]
+        { per_page: 3 }          | %w[other-topic GitLab Git]
+        { page: 0, per_page: 1 } | %w[other-topic]
+        { page: 0, per_page: 2 } | %w[other-topic GitLab]
+        { page: 1, per_page: 1 } | %w[other-topic]
+        { page: 1, per_page: 2 } | %w[other-topic GitLab]
+        { page: 2, per_page: 1 } | %w[GitLab]
+        { page: 2, per_page: 2 } | %w[Git]
+        { page: 3, per_page: 1 } | %w[Git]
+        { page: 3, per_page: 2 } | %w[]
+        { page: 4, per_page: 1 } | %w[]
+        { page: 4, per_page: 2 } | %w[]
+      end
+
+      with_them do
+        it 'returns paginated topics' do
+          get api('/topics'), params: params
+
+          expect(json_response.map { |t| t['name'] }).to eq(result)
+        end
+      end
+    end
+  end
+
+  describe 'GET /topic/:id', :aggregate_failures do
+    it 'returns topic' do
+      get api("/topics/#{topic_2.id}")
+
+      expect(response).to have_gitlab_http_status(:ok)
+
+      expect(json_response['id']).to eq(topic_2.id)
+      expect(json_response['name']).to eq('GitLab')
+      expect(json_response['total_projects_count']).to eq(2)
+    end
+
+    it 'returns 404 for non existing id' do
+      get api("/topics/#{non_existing_record_id}")
+
+      expect(response).to have_gitlab_http_status(:not_found)
+    end
+
+    it 'returns 400 for invalid `id` parameter' do
+      get api('/topics/invalid')
+
+      expect(response).to have_gitlab_http_status(:bad_request)
+      expect(json_response['error']).to eql('id is invalid')
+    end
+  end
+
+  describe 'POST /topics', :aggregate_failures do
+    context 'as administrator' do
+      it 'creates a topic' do
+        post api('/topics/', admin), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:created)
+        expect(json_response['name']).to eq('my-topic')
+        expect(Projects::Topic.find(json_response['id']).name).to eq('my-topic')
+      end
+
+      it 'creates a topic with avatar and description' do
+        workhorse_form_with_file(
+          api('/topics/', admin),
+          file_key: :avatar,
+          params: { name: 'my-topic', description: 'my description...', avatar: file }
+        )
+
+        expect(response).to have_gitlab_http_status(:created)
+        expect(json_response['description']).to eq('my description...')
+        expect(json_response['avatar_url']).to end_with('dk.png')
+      end
+
+      it 'returns 400 if name is missing' do
+        post api('/topics/', admin)
+
+        expect(response).to have_gitlab_http_status(:bad_request)
+        expect(json_response['error']).to eql('name is missing')
+      end
+    end
+
+    context 'as normal user' do
+      it 'returns 403 Forbidden' do
+        post api('/topics/', user), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end
+    end
+
+    context 'as anonymous' do
+      it 'returns 401 Unauthorized' do
+        post api('/topics/'), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:unauthorized)
+      end
+    end
+  end
+
+  describe 'PUT /topics', :aggregate_failures do
+    context 'as administrator' do
+      it 'updates a topic' do
+        put api("/topics/#{topic_3.id}", admin), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:ok)
+        expect(json_response['name']).to eq('my-topic')
+        expect(topic_3.reload.name).to eq('my-topic')
+      end
+
+      it 'updates a topic with avatar and description' do
+        workhorse_form_with_file(
+          api("/topics/#{topic_3.id}", admin),
+          method: :put,
+          file_key: :avatar,
+          params: { description: 'my description...', avatar: file }
+        )
+
+        expect(response).to have_gitlab_http_status(:ok)
+        expect(json_response['description']).to eq('my description...')
+        expect(json_response['avatar_url']).to end_with('dk.png')
+      end
+
+      it 'returns 404 for non existing id' do
+        put api("/topics/#{non_existing_record_id}", admin), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+
+      it 'returns 400 for invalid `id` parameter' do
+        put api('/topics/invalid', admin), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:bad_request)
+        expect(json_response['error']).to eql('id is invalid')
+      end
+    end
+
+    context 'as normal user' do
+      it 'returns 403 Forbidden' do
+        put api("/topics/#{topic_3.id}", user), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end
+    end
+
+    context 'as anonymous' do
+      it 'returns 401 Unauthorized' do
+        put api("/topics/#{topic_3.id}"), params: { name: 'my-topic' }
+
+        expect(response).to have_gitlab_http_status(:unauthorized)
+      end
+    end
+  end
+end
diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb
index cd8387de686a14006798eaf0d90c850eb6f7f926..83bda6e03b13bcf53e77cf036aaf3d96e48f525c 100644
--- a/spec/support/helpers/workhorse_helpers.rb
+++ b/spec/support/helpers/workhorse_helpers.rb
@@ -24,7 +24,12 @@ def workhorse_internal_api_request_header
 
   # workhorse_post_with_file will transform file_key inside params as if it was disk accelerated by workhorse
   def workhorse_post_with_file(url, file_key:, params:)
-    workhorse_request_with_file(:post, url,
+    workhorse_form_with_file(url, method: :post, file_key: file_key, params: params)
+  end
+
+  # workhorse_form_with_file will transform file_key inside params as if it was disk accelerated by workhorse
+  def workhorse_form_with_file(url, file_key:, params:, method: :post)
+    workhorse_request_with_file(method, url,
                                 file_key: file_key,
                                 params: params,
                                 env: { 'CONTENT_TYPE' => 'multipart/form-data' },
diff --git a/workhorse/internal/upstream/routes.go b/workhorse/internal/upstream/routes.go
index b4a3f85e4c416a10bb909125efaf0b71b4bb6695..22b30fe8a632f544fe1d25a2f34954a895e1a16c 100644
--- a/workhorse/internal/upstream/routes.go
+++ b/workhorse/internal/upstream/routes.go
@@ -60,6 +60,7 @@ const (
 	geoGitProjectPattern = `^/[^-].+\.git/` // Prevent matching routes like /-/push_from_secondary
 	projectPattern       = `^/([^/]+/){1,}[^/]+/`
 	apiProjectPattern    = apiPattern + `v4/projects/[^/]+/` // API: Projects can be encoded via group%2Fsubgroup%2Fproject
+	apiTopicPattern      = apiPattern + `v4/topics`
 	snippetUploadPattern = `^/uploads/personal_snippet`
 	userUploadPattern    = `^/uploads/user`
 	importPattern        = `^/import/`
@@ -295,6 +296,8 @@ func configureRoutes(u *upstream) {
 		// Overall status can be seen at https://gitlab.com/groups/gitlab-org/-/epics/1802#current-status
 		u.route("POST", apiProjectPattern+`wikis/attachments\z`, uploadAccelerateProxy),
 		u.route("POST", apiPattern+`graphql\z`, uploadAccelerateProxy),
+		u.route("POST", apiTopicPattern, uploadAccelerateProxy),
+		u.route("PUT", apiTopicPattern, uploadAccelerateProxy),
 		u.route("POST", apiPattern+`v4/groups/import`, upload.Accelerate(api, signingProxy, preparers.uploads)),
 		u.route("POST", apiPattern+`v4/projects/import`, upload.Accelerate(api, signingProxy, preparers.uploads)),
 
diff --git a/workhorse/upload_test.go b/workhorse/upload_test.go
index 24c14bb12aa30ddc3ef3433614f12e0fa730d2a5..ed3859dfc981b5934c528a84539e3c4cbcccbc58 100644
--- a/workhorse/upload_test.go
+++ b/workhorse/upload_test.go
@@ -123,6 +123,8 @@ func TestAcceleratedUpload(t *testing.T) {
 		{"POST", `/api/v4/projects/group%2Fproject/wikis/attachments`, false},
 		{"POST", `/api/v4/projects/group%2Fsubgroup%2Fproject/wikis/attachments`, false},
 		{"POST", `/api/graphql`, false},
+		{"POST", `/api/v4/topics`, false},
+		{"PUT", `/api/v4/topics`, false},
 		{"PUT", "/api/v4/projects/9001/packages/nuget/v1/files", true},
 		{"PUT", "/api/v4/projects/group%2Fproject/packages/nuget/v1/files", true},
 		{"PUT", "/api/v4/projects/group%2Fsubgroup%2Fproject/packages/nuget/v1/files", true},