diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 58f40e10fd4289cb97f21b7fc77ec2bfc8722c4c..e4354eaa45237ff7217b048802f1e48377ae9212 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -72,7 +72,19 @@ def activity
 
       format.json do
         load_events
-        pager_json("events/_events", @events.count, events: @events)
+
+        if Feature.enabled?(:profile_tabs_vue, current_user)
+          @events = if user.include_private_contributions?
+                      @events
+                    else
+                      @events.select { |event| event.visible_to_user?(current_user) }
+                    end
+
+          render json: ::Profile::EventSerializer.new(current_user: current_user, target_user: user)
+                                                 .represent(@events)
+        else
+          pager_json("events/_events", @events.count, events: @events)
+        end
       end
     end
   end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 590659be0cb2c64ff98d6fc9a8eb541ab885c4cd..70b40221c35eaa57be2d844d631908e6023ccf46 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -2,6 +2,7 @@
 
 module MergeRequestsHelper
   include Gitlab::Utils::StrongMemoize
+  include CompareHelper
 
   def create_mr_button_from_event?(event)
     create_mr_button?(from: event.branch_name, source_project: event.project)
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index 2f2fb1aa3ba856cf656b9b054596d11f9679e234..a098db7fbbc8b41495514139d29cdf9a46c8c622 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -9,7 +9,7 @@ def initialize(event, **attributes)
     @visible_to_user_cache = ActiveSupport::Cache::MemoryStore.new
   end
 
-  # Caching `visible_to_user?` method in the presenter beause it might be called multiple times.
+  # Caching `visible_to_user?` method in the presenter because it might be called multiple times.
   delegator_override :visible_to_user?
   def visible_to_user?(user = nil)
     @visible_to_user_cache.fetch(user&.id) { super(user) }
diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fe90265c888b5b70917160461396718b3d5570a0
--- /dev/null
+++ b/app/serializers/profile/event_entity.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Profile
+  class EventEntity < Grape::Entity
+    include ActionView::Helpers::SanitizeHelper
+    include RequestAwareEntity
+    include MarkupHelper
+    include MergeRequestsHelper
+    include EventsHelper
+
+    expose :created_at, if: ->(event) { include_private_event?(event) }
+    expose(:action, if: ->(event) { include_private_event?(event) }) { |event| event_action(event) }
+
+    expose :ref, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
+      expose(:type) { |event| event.ref_type } # rubocop:disable Style/SymbolProc
+      expose(:count) { |event| event.ref_count } # rubocop:disable Style/SymbolProc
+      expose(:name) { |event| event.ref_name } # rubocop:disable Style/SymbolProc
+      expose(:path) { |event| ref_path(event) }
+    end
+
+    expose :commit, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
+      expose(:truncated_sha) { |event| Commit.truncate_sha(event.commit_id) }
+      expose(:path) { |event| project_commit_path(event.project, event.commit_id) }
+      expose(:title) { |event| event_commit_title(event.commit_title) }
+      expose(:count) { |event| event.commits_count } # rubocop:disable Style/SymbolProc
+      expose(:create_mr_path) { |event| commit_create_mr_path(event) }
+      expose(:from_truncated_sha) { |event| commit_from(event) if event.commit_from }
+      expose(:to_truncated_sha) { |event| Commit.truncate_sha(event.commit_to) if event.commit_to }
+
+      expose :compare_path, if: ->(event) { event.push_with_commits? && event.commits_count > 1 } do |event|
+        project = event.project
+        from = event.md_ref? ? event.commit_from : project.default_branch
+        project_compare_path(project, from: from, to: event.commit_to)
+      end
+    end
+
+    expose :author, if: ->(event) { include_private_event?(event) } do
+      expose(:id) { |event| event.author.id }
+      expose(:name) { |event| event.author.name }
+      expose(:path) { |event| event.author.username }
+    end
+
+    expose :target, if: ->(event) { event.visible_to_user?(current_user) } do
+      expose :target_type
+
+      expose(:title) { |event| event.target_title } # rubocop:disable Style/SymbolProc
+      expose :target_url, if: ->(event) { event.target } do |event|
+        Gitlab::UrlBuilder.build(event.target, only_path: true)
+      end
+      expose :reference_link_text, if: ->(event) { event.target&.respond_to?(:reference_link_text) } do |event|
+        event.target.reference_link_text
+      end
+      expose :first_line_in_markdown, if: ->(event) { event.note? && event.target && event.project } do |event|
+        first_line_in_markdown(event.target, :note, 150, project: event.project)
+      end
+      expose :attachment, if: ->(event) { event.note? && event.target&.attachment } do
+        expose(:url) { |event| event.target.attachment.url }
+      end
+    end
+
+    expose :resource_parent, if: ->(event) { event.visible_to_user?(current_user) } do
+      expose(:type) { |event| resource_parent_type(event) }
+      expose(:full_name) { |event| event.resource_parent&.full_name }
+      expose(:full_path) { |event| event.resource_parent&.full_path }
+    end
+
+    private
+
+    def current_user
+      request.current_user
+    end
+
+    def target_user
+      request.target_user
+    end
+
+    def include_private_event?(event)
+      event.visible_to_user?(current_user) || target_user.include_private_contributions?
+    end
+
+    def commit_from(event)
+      if event.md_ref?
+        Commit.truncate_sha(event.commit_from)
+      else
+        event.project.default_branch
+      end
+    end
+
+    def event_action(event)
+      if event.visible_to_user?(current_user)
+        event.action
+      elsif target_user.include_private_contributions?
+        'private'
+      end
+    end
+
+    def ref_path(event)
+      project = event.project
+      commits_link = project_commits_path(project, event.ref_name)
+      should_link = if event.tag?
+                      project.repository.tag_exists?(event.ref_name)
+                    else
+                      project.repository.branch_exists?(event.ref_name)
+                    end
+
+      should_link ? commits_link : nil
+    end
+
+    def commit_create_mr_path(event)
+      if event.new_ref? &&
+          create_mr_button_from_event?(event) &&
+          event.authored_by?(current_user)
+        create_mr_path_from_push_event(event)
+      end
+    end
+
+    def resource_parent_type(event)
+      if event.project
+        "project"
+      elsif event.group
+        "group"
+      end
+    end
+  end
+end
diff --git a/app/serializers/profile/event_serializer.rb b/app/serializers/profile/event_serializer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c7f23d61fe1b2676e6b394ca9f6e60d346432fd4
--- /dev/null
+++ b/app/serializers/profile/event_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Profile
+  class EventSerializer < BaseSerializer
+    entity Profile::EventEntity
+  end
+end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index 11d8be24e0603cbb5b2fb25bac9c398fcccdb816..75ae0c970c02403b374d5321a48cff98aedf31b5 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -174,39 +174,95 @@
     end
 
     context 'requested in json format' do
-      let(:project) { create(:project) }
+      context 'when profile_tabs_vue feature flag is turned OFF' do
+        let(:project) { create(:project) }
 
-      before do
-        project.add_developer(user)
-        Gitlab::DataBuilder::Push.build_sample(project, user)
+        before do
+          project.add_developer(user)
+          Gitlab::DataBuilder::Push.build_sample(project, user)
+          stub_feature_flags(profile_tabs_vue: false)
+          sign_in(user)
+        end
 
-        sign_in(user)
-      end
+        it 'loads events' do
+          get user_activity_url user.username, format: :json
 
-      it 'loads events' do
-        get user_activity_url user.username, format: :json
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body)['count']).to eq(1)
+        end
 
-        expect(response.media_type).to eq('application/json')
-        expect(Gitlab::Json.parse(response.body)['count']).to eq(1)
-      end
+        it 'hides events if the user cannot read cross project' do
+          allow(Ability).to receive(:allowed?).and_call_original
+          expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
 
-      it 'hides events if the user cannot read cross project' do
-        allow(Ability).to receive(:allowed?).and_call_original
-        expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+          get user_activity_url user.username, format: :json
 
-        get user_activity_url user.username, format: :json
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+        end
 
-        expect(response.media_type).to eq('application/json')
-        expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+        it 'hides events if the user has a private profile' do
+          Gitlab::DataBuilder::Push.build_sample(project, private_user)
+
+          get user_activity_url private_user.username, format: :json
+
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+        end
       end
 
-      it 'hides events if the user has a private profile' do
-        Gitlab::DataBuilder::Push.build_sample(project, private_user)
+      context 'when profile_tabs_vue feature flag is turned ON' do
+        let(:project) { create(:project) }
 
-        get user_activity_url private_user.username, format: :json
+        before do
+          project.add_developer(user)
+          Gitlab::DataBuilder::Push.build_sample(project, user)
+          stub_feature_flags(profile_tabs_vue: true)
+          sign_in(user)
+        end
 
-        expect(response.media_type).to eq('application/json')
-        expect(Gitlab::Json.parse(response.body)['count']).to eq(0)
+        it 'loads events' do
+          get user_activity_url user.username, format: :json
+
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body).count).to eq(1)
+        end
+
+        it 'hides events if the user cannot read cross project' do
+          allow(Ability).to receive(:allowed?).and_call_original
+          expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false }
+
+          get user_activity_url user.username, format: :json
+
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body).count).to eq(0)
+        end
+
+        it 'hides events if the user has a private profile' do
+          Gitlab::DataBuilder::Push.build_sample(project, private_user)
+
+          get user_activity_url private_user.username, format: :json
+
+          expect(response.media_type).to eq('application/json')
+          expect(Gitlab::Json.parse(response.body).count).to eq(0)
+        end
+
+        it 'hides events if the user has a private profile' do
+          project = create(:project, :private)
+          private_event_user = create(:user, include_private_contributions: true)
+          push_data = Gitlab::DataBuilder::Push.build_sample(project, private_event_user)
+          EventCreateService.new.push(project, private_event_user, push_data)
+
+          get user_activity_url private_event_user.username, format: :json
+
+          response_body = Gitlab::Json.parse(response.body)
+          event = response_body.first
+          expect(response.media_type).to eq('application/json')
+          expect(response_body.count).to eq(1)
+          expect(event).to include('created_at', 'author', 'action')
+          expect(event['action']).to eq('private')
+          expect(event).not_to include('ref', 'commit', 'target', 'resource_parent')
+        end
       end
     end
   end
diff --git a/spec/serializers/profile/event_entity_spec.rb b/spec/serializers/profile/event_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1551fc764660179f298463e83c98680bd3ae3506
--- /dev/null
+++ b/spec/serializers/profile/event_entity_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Profile::EventEntity, feature_category: :user_profile do
+  let_it_be(:group) { create(:group) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+  let_it_be(:project) { build(:project_empty_repo, group: group) }
+  let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+  let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate
+
+  let(:target_user) { user }
+  let(:event) { build(:event, :merged, author: user, project: project, target: merge_request) }
+  let(:request) { double(described_class, current_user: user, target_user: target_user) } # rubocop:disable RSpec/VerifiedDoubles
+  let(:entity) { described_class.new(event, request: request) }
+
+  subject { entity.as_json }
+
+  before do
+    group.add_maintainer(user)
+  end
+
+  it 'exposes fields', :aggregate_failures do
+    expect(subject[:created_at]).to eq(event.created_at)
+    expect(subject[:action]).to eq(event.action)
+    expect(subject[:author][:id]).to eq(target_user.id)
+    expect(subject[:author][:name]).to eq(target_user.name)
+    expect(subject[:author][:path]).to eq(target_user.username)
+  end
+
+  context 'for push events' do
+    let_it_be(:commit_from) { Gitlab::Git::BLANK_SHA }
+    let_it_be(:commit_title) { 'My commit' }
+    let(:event) { build(:push_event, project: project, author: target_user) }
+
+    it 'exposes ref fields' do
+      build(:push_event_payload, event: event, ref_count: 3)
+
+      expect(subject[:ref][:type]).to eq(event.ref_type)
+      expect(subject[:ref][:count]).to eq(event.ref_count)
+      expect(subject[:ref][:name]).to eq(event.ref_name)
+      expect(subject[:ref][:path]).to be_nil
+    end
+
+    shared_examples 'returns ref path' do
+      specify do
+        expect(subject[:ref][:path]).to be_present
+      end
+    end
+
+    context 'with tag' do
+      before do
+        allow(project.repository).to receive(:tag_exists?).and_return(true)
+        build(:push_event_payload, event: event, ref_type: :tag)
+      end
+
+      it_behaves_like 'returns ref path'
+    end
+
+    context 'with branch' do
+      before do
+        allow(project.repository).to receive(:branch_exists?).and_return(true)
+        build(:push_event_payload, event: event, ref_type: :branch)
+      end
+
+      it_behaves_like 'returns ref path'
+    end
+
+    it 'exposes commit fields' do
+      build(:push_event_payload, event: event, commit_title: commit_title, commit_from: commit_from, commit_count: 2)
+
+      compare_path = "/#{group.path}/#{project.path}/-/compare/#{commit_from}...#{event.commit_to}"
+      expect(subject[:commit][:compare_path]).to eq(compare_path)
+      expect(event.commit_id).to include(subject[:commit][:truncated_sha])
+      expect(subject[:commit][:path]).to be_present
+      expect(subject[:commit][:title]).to eq(commit_title)
+      expect(subject[:commit][:count]).to eq(2)
+      expect(commit_from).to include(subject[:commit][:from_truncated_sha])
+      expect(event.commit_to).to include(subject[:commit][:to_truncated_sha])
+      expect(subject[:commit][:create_mr_path]).to be_nil
+    end
+
+    it 'exposes create_mr_path' do
+      allow(project).to receive(:default_branch).and_return('main')
+      allow(project.repository).to receive(:branch_exists?).and_return(true)
+      build(:push_event_payload, event: event, action: :created, commit_from: commit_from, commit_count: 2)
+
+      new_mr_path = "/#{group.path}/#{project.path}/-/merge_requests/new?" \
+                    "merge_request%5Bsource_branch%5D=#{event.branch_name}"
+      expect(subject[:commit][:create_mr_path]).to eq(new_mr_path)
+    end
+  end
+
+  context 'with target' do
+    let_it_be(:note) { build(:note_on_merge_request, :with_attachment, noteable: merge_request, project: project) }
+
+    context 'when target does not responds to :reference_link_text' do
+      let(:event) { build(:event, :commented, project: project, target: note, author: target_user) }
+
+      it 'exposes target fields' do
+        expect(subject[:target]).not_to include(:reference_link_text)
+        expect(subject[:target][:target_type]).to eq(note.class.to_s)
+        expect(subject[:target][:target_url]).to be_present
+        expect(subject[:target][:title]).to eq(note.title)
+        expect(subject[:target][:first_line_in_markdown]).to be_present
+        expect(subject[:target][:attachment][:url]).to eq(note.attachment.url)
+      end
+    end
+
+    context 'when target responds to :reference_link_text' do
+      it 'exposes reference_link_text' do
+        expect(subject[:target][:reference_link_text]).to eq(merge_request.reference_link_text)
+      end
+    end
+  end
+
+  context 'with resource parent' do
+    it 'exposes resource parent fields' do
+      resource_parent = event.resource_parent
+
+      expect(subject[:resource_parent][:type]).to eq('project')
+      expect(subject[:resource_parent][:full_name]).to eq(resource_parent.full_name)
+      expect(subject[:resource_parent][:full_path]).to eq(resource_parent.full_path)
+    end
+  end
+
+  context 'for private events' do
+    let(:event) { build(:event, :merged, author: target_user) }
+
+    context 'when include_private_contributions? is true' do
+      let(:target_user) { build(:user, include_private_contributions: true) }
+
+      it 'exposes only created_at, action, and author', :aggregate_failures do
+        expect(subject[:created_at]).to eq(event.created_at)
+        expect(subject[:action]).to eq('private')
+        expect(subject[:author][:id]).to eq(target_user.id)
+        expect(subject[:author][:name]).to eq(target_user.name)
+        expect(subject[:author][:path]).to eq(target_user.username)
+
+        is_expected.not_to include(:ref, :commit, :target, :resource_parent)
+      end
+    end
+
+    context 'when include_private_contributions? is false' do
+      let(:target_user) { build(:user, include_private_contributions: false) }
+
+      it { is_expected.to be_empty }
+    end
+  end
+end