diff --git a/app/assets/javascripts/pages/dashboard/todos/vue/index.js b/app/assets/javascripts/pages/dashboard/todos/vue/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3829b9845dff19cc786739cab20aad1dff82484a
--- /dev/null
+++ b/app/assets/javascripts/pages/dashboard/todos/vue/index.js
@@ -0,0 +1,3 @@
+import initTodosApp from '~/todos';
+
+initTodosApp();
diff --git a/app/assets/javascripts/todos/components/todos_app.vue b/app/assets/javascripts/todos/components/todos_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..282d3173b0110b6100727c409a8accbeba396377
--- /dev/null
+++ b/app/assets/javascripts/todos/components/todos_app.vue
@@ -0,0 +1,5 @@
+<script>
+export default {};
+</script>
+
+<template><div></div></template>
diff --git a/app/assets/javascripts/todos/index.js b/app/assets/javascripts/todos/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..6bd24f65d3fb67e9356fe52bb22dff6bb58aa8ea
--- /dev/null
+++ b/app/assets/javascripts/todos/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import TodosApp from './components/todos_app.vue';
+
+export default () => {
+  const el = document.getElementById('js-todos-app-root');
+
+  if (!el) {
+    return false;
+  }
+
+  return new Vue({
+    el,
+    render(createElement) {
+      return createElement(TodosApp);
+    },
+  });
+};
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index b1a8ac351bf9b1012135a6caef8c6086a6c52e5c..d06d053a3d11c212fb33167daa72400da2ca4bb8 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -22,6 +22,10 @@ def index
     @allowed_todos = ::Todos::AllowedTargetFilterService.new(@todos, current_user).execute
   end
 
+  def vue
+    redirect_to(dashboard_todos_path, status: :found) unless Feature.enabled?(:todos_vue_application, current_user)
+  end
+
   def destroy
     todo = current_user.todos.find(params[:id])
 
diff --git a/app/views/dashboard/todos/_user_has_no_todos.html.haml b/app/views/dashboard/todos/_user_has_no_todos.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..fa383ecba1f02eae80e23934cd71255b7aca2a36
--- /dev/null
+++ b/app/views/dashboard/todos/_user_has_no_todos.html.haml
@@ -0,0 +1,8 @@
+= render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-todos-md.svg',
+      title: s_("Todos|Your To-Do List shows what to work on next")) do |c|
+
+  - c.with_description do
+    %p
+      = (s_("Todos|When an issue or merge request is assigned to you, or when you receive a %{strongStart}@mention%{strongEnd} in a comment, this automatically triggers a new item in your To-Do List.") % { strongStart: '<strong>', strongEnd: '</strong>' }).html_safe
+    %p
+      = s_("Todos|It's how you always know what to work on next.")
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index ee92fd85774984e57144508001d6aea0ec2b1643..4bc7e7c5f29ebfbbe3d2ce6cd99d0875e7fbeff6 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -114,11 +114,4 @@
             = link_to s_("Todos|Do you want to remove the filters?"), todos_filter_path(without: [:project_id, :author_id, :type, :action_id])
 
   - else
-    = render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-todos-md.svg',
-      title: s_("Todos|Your To-Do List shows what to work on next")) do |c|
-
-      - c.with_description do
-        %p
-          = (s_("Todos|When an issue or merge request is assigned to you, or when you receive a %{strongStart}@mention%{strongEnd} in a comment, this automatically triggers a new item in your To-Do List.") % { strongStart: '<strong>', strongEnd: '</strong>' }).html_safe
-        %p
-          = s_("Todos|It's how you always know what to work on next.")
+    = render 'dashboard/todos/user_has_no_todos'
diff --git a/app/views/dashboard/todos/vue.html.haml b/app/views/dashboard/todos/vue.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a4e74116693182718fa5fc40e342b1f9d15e2035
--- /dev/null
+++ b/app/views/dashboard/todos/vue.html.haml
@@ -0,0 +1,17 @@
+- page_title _("To-Do List")
+
+= render_two_factor_auth_recovery_settings_check
+= render_dashboard_ultimate_trial(current_user)
+
+= render_if_exists 'shared/dashboard/saml_reauth_notice',
+  groups_requiring_saml_reauth: todo_groups_requiring_saml_reauth(@todos)
+
+- add_page_specific_style 'page_bundles/todos'
+- add_issuable_stylesheet
+- user_has_todos = current_user.todos.any?
+
+- if user_has_todos
+  = render ::Layouts::PageHeadingComponent.new(_('To-Do List'))
+  #js-todos-app-root
+- else
+  = render 'dashboard/todos/user_has_no_todos'
diff --git a/config/feature_flags/wip/todos_vue_application.yml b/config/feature_flags/wip/todos_vue_application.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c06f58f74d6818fc3ff951f9998dbcbbab60887f
--- /dev/null
+++ b/config/feature_flags/wip/todos_vue_application.yml
@@ -0,0 +1,9 @@
+---
+name: todos_vue_application
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/464069
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162587
+rollout_issue_url:
+milestone: '17.4'
+group: group::personal productivity
+type: wip
+default_enabled: false
diff --git a/config/routes/dashboard.rb b/config/routes/dashboard.rb
index a9b4582a1a4ab5188c296e93cd832986858c2581..a78775f9afef3adac4a40446e4109046f634a752 100644
--- a/config/routes/dashboard.rb
+++ b/config/routes/dashboard.rb
@@ -16,6 +16,7 @@
 
     resources :todos, only: [:index, :destroy] do
       collection do
+        get :vue
         delete :destroy_all
         patch :bulk_restore
       end
diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb
index b66ad8f1e3c2304d63cda9d57b1e475b09f30c22..9f5bd1a1a56be81875aae70f503110dfcc7d6c66 100644
--- a/spec/controllers/dashboard/todos_controller_spec.rb
+++ b/spec/controllers/dashboard/todos_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe Dashboard::TodosController do
+RSpec.describe Dashboard::TodosController, feature_category: :notifications do
   let_it_be(:user) { create(:user) }
   let_it_be(:project) { create(:project, developers: user) }
   let_it_be(:author) { create(:user) }
@@ -157,6 +157,32 @@
     end
   end
 
+  describe 'GET #vue' do
+    context 'with todos_vue_application on' do
+      before do
+        stub_feature_flags(todos_vue_application: true)
+      end
+
+      it 'renders 200' do
+        get :vue
+
+        expect(response).to have_gitlab_http_status(:ok)
+      end
+    end
+
+    context 'with todos_vue_application off' do
+      before do
+        stub_feature_flags(todos_vue_application: false)
+      end
+
+      it 'redirects to #index' do
+        get :vue
+
+        expect(response).to redirect_to dashboard_todos_path
+      end
+    end
+  end
+
   describe 'PATCH #restore' do
     let(:todo) { create(:todo, :done, user: user, project: project, author: author) }