diff --git a/ee/app/assets/javascripts/ci/merge_trains/index.js b/ee/app/assets/javascripts/ci/merge_trains/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..e1507fed9be30bb2c68f449959ad5fb518b7e69b
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/merge_trains/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import MergeTrainsApp from './merge_trains_app.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+  defaultClient: createDefaultClient(),
+});
+
+export const initMergeTrainsApp = () => {
+  const el = document.querySelector('#js-merge-trains');
+
+  if (!el) {
+    return false;
+  }
+
+  const { fullPath } = el.dataset;
+
+  return new Vue({
+    el,
+    name: 'MergeTrainsRoot',
+    apolloProvider,
+    provide: {
+      fullPath,
+    },
+    render(createElement) {
+      return createElement(MergeTrainsApp);
+    },
+  });
+};
diff --git a/ee/app/assets/javascripts/ci/merge_trains/merge_trains_app.vue b/ee/app/assets/javascripts/ci/merge_trains/merge_trains_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b3f45a05c070066667e74f95b5a7d0818f768487
--- /dev/null
+++ b/ee/app/assets/javascripts/ci/merge_trains/merge_trains_app.vue
@@ -0,0 +1,14 @@
+<script>
+export default {
+  name: 'MergeTrainsApp',
+  inject: {
+    fullPath: {
+      default: '',
+    },
+  },
+};
+</script>
+
+<template>
+  <div></div>
+</template>
diff --git a/ee/app/assets/javascripts/pages/projects/merge_trains/index.js b/ee/app/assets/javascripts/pages/projects/merge_trains/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..fd16debc3c81149254ab9aef626e6b96fe48e3fc
--- /dev/null
+++ b/ee/app/assets/javascripts/pages/projects/merge_trains/index.js
@@ -0,0 +1,3 @@
+import { initMergeTrainsApp } from 'ee/ci/merge_trains/index';
+
+initMergeTrainsApp();
diff --git a/ee/app/controllers/projects/merge_trains_controller.rb b/ee/app/controllers/projects/merge_trains_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..353f3a8ab6b5babb7402ce884e21e2872dec67bd
--- /dev/null
+++ b/ee/app/controllers/projects/merge_trains_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Projects
+  class MergeTrainsController < Projects::ApplicationController
+    feature_category :merge_trains
+
+    before_action :authorize_read_merge_train!
+    before_action :check_enabled!
+
+    def index; end
+
+    private
+
+    def check_enabled!
+      render_404 unless Feature.enabled?(:merge_trains_viz, project)
+    end
+  end
+end
diff --git a/ee/app/views/projects/merge_trains/index.html.haml b/ee/app/views/projects/merge_trains/index.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..1d6cdc7a78eea8c6d5f728122acf6c705885e6d0
--- /dev/null
+++ b/ee/app/views/projects/merge_trains/index.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Merge trains')
+
+#js-merge-trains{ data: { full_path: @project.full_path } }
diff --git a/ee/config/feature_flags/wip/merge_trains_viz.yml b/ee/config/feature_flags/wip/merge_trains_viz.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b9b9e01aad31e64cea8a1335751e172ff37091a1
--- /dev/null
+++ b/ee/config/feature_flags/wip/merge_trains_viz.yml
@@ -0,0 +1,9 @@
+---
+name: merge_trains_viz
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454179
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/149025
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/455342
+milestone: '16.11'
+group: group::pipeline execution
+type: wip
+default_enabled: false
diff --git a/ee/config/routes/project.rb b/ee/config/routes/project.rb
index 91c74bba79314ce589057cde34023ae3cea5e843..10dfb68c95539c3eb9d15420cfbb8098e2319f65 100644
--- a/ee/config/routes/project.rb
+++ b/ee/config/routes/project.rb
@@ -159,6 +159,8 @@
         end
 
         resources :compliance_frameworks, only: [:create]
+
+        resources :merge_trains, only: [:index]
       end
       # End of the /-/ scope.
 
diff --git a/ee/spec/frontend/ci/merge_trains/merge_trains_app_spec.js b/ee/spec/frontend/ci/merge_trains/merge_trains_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..80234e97b30eae6216eef63e06fd13be4f0b7ca4
--- /dev/null
+++ b/ee/spec/frontend/ci/merge_trains/merge_trains_app_spec.js
@@ -0,0 +1,16 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import MergeTrainsApp from 'ee/ci/merge_trains/merge_trains_app.vue';
+
+describe('MergeTrainsApp', () => {
+  let wrapper;
+
+  const createComponent = () => {
+    wrapper = shallowMountExtended(MergeTrainsApp, { provide: { fullPath: 'namespace/project' } });
+  };
+
+  it('renders the merge trains app', () => {
+    createComponent();
+
+    expect(wrapper.findComponent(MergeTrainsApp).exists()).toBe(true);
+  });
+});
diff --git a/ee/spec/requests/projects/merge_trains_controller_spec.rb b/ee/spec/requests/projects/merge_trains_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ef15f3f71ea2a0a9e36ef5fdb23cb99a12115e75
--- /dev/null
+++ b/ee/spec/requests/projects/merge_trains_controller_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::MergeTrainsController, type: :request, feature_category: :merge_trains do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:project) { create(:project) }
+
+  describe 'GET /:namespace/:project/-/merge_trains' do
+    subject(:request) { get project_merge_trains_url(project) }
+
+    before_all do
+      project.add_maintainer(user)
+    end
+
+    before do
+      sign_in(user)
+    end
+
+    context 'when feature flag "merge_trains_viz" is enabled' do
+      it 'renders the merge trains index template' do
+        request
+
+        expect(response).to have_gitlab_http_status(:ok)
+        expect(response).to render_template('projects/merge_trains/index')
+      end
+    end
+
+    context 'when feature flag "merge_trains_viz" is disabled' do
+      before do
+        stub_feature_flags(merge_trains_viz: false)
+      end
+
+      it 'returns "not found response"' do
+        request
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+end