diff --git a/app/assets/javascripts/entrypoints/main.js b/app/assets/javascripts/entrypoints/main.js
index 003a2f31829aa974b4f1d5c0cbafcca91643b6ec..b627bb195a93945c93424ad40f10cfe15389b9d7 100644
--- a/app/assets/javascripts/entrypoints/main.js
+++ b/app/assets/javascripts/entrypoints/main.js
@@ -1,6 +1 @@
 import '../main';
-import { runModules } from '~/run_modules';
-
-const modules = import.meta.glob('../pages/**/index.js');
-
-runModules(modules, 'CE');
diff --git a/app/assets/javascripts/entrypoints/main_ee.js b/app/assets/javascripts/entrypoints/main_ee.js
deleted file mode 100644
index 5193bdd95c45e6cf7d7a007460df523badb0c811..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/entrypoints/main_ee.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { runModules } from '~/run_modules';
-
-const modules = import.meta.glob('../../../../ee/app/assets/javascripts/pages/**/index.js');
-
-runModules(modules, 'EE');
diff --git a/app/assets/javascripts/entrypoints/main_jh.js b/app/assets/javascripts/entrypoints/main_jh.js
deleted file mode 100644
index 8681ea933426ed5f10149449313b0c00fb1bc5bb..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/entrypoints/main_jh.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { runModules } from '~/run_modules';
-
-const modules = import.meta.glob('../../../../jh/app/assets/javascripts/pages/**/index.js');
-
-runModules(modules, 'JH');
diff --git a/app/assets/javascripts/run_modules.js b/app/assets/javascripts/run_modules.js
deleted file mode 100644
index b68180781b3803291d2578eb79d1eeec6970dd0f..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/run_modules.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const paths = [];
-const allModules = {};
-
-const prefixes = {
-  CE: '../pages/',
-  EE: '../../../../ee/app/assets/javascripts/pages/',
-  JH: '../../../../jh/app/assets/javascripts/pages/',
-};
-
-const editionExcludes = {
-  JH: [],
-  EE: [prefixes.JH],
-  CE: [prefixes.EE, prefixes.JH],
-};
-
-const runWithExcludes = (edition) => {
-  const prefix = prefixes[edition];
-  const excludes = editionExcludes[edition];
-  paths.forEach((path) => {
-    const hasDuplicateEntrypoint = excludes.some(
-      (editionPrefix) => `${editionPrefix}${path}` in allModules,
-    );
-    if (!hasDuplicateEntrypoint) allModules[`${prefix}${path}`]?.();
-  });
-};
-
-let pathsPopulated = false;
-
-const populatePaths = () => {
-  if (pathsPopulated) return;
-  paths.push(
-    ...document
-      .querySelector('meta[name="controller-path"]')
-      .content.split('/')
-      .map((part, index, arr) => `${[...arr.slice(0, index), part].join('/')}/index.js`),
-  );
-  pathsPopulated = true;
-};
-
-export const runModules = (modules, edition) => {
-  populatePaths();
-  Object.assign(allModules, modules);
-  // wait before all modules have been collected to exclude duplicates between CE and EE\JH
-  // <script> runs as a macrotask, can't schedule with promises here
-  requestAnimationFrame(() => {
-    runWithExcludes(edition);
-  });
-};
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index adb576d22cdcdef21d022f0cac9c345c51d48263..2336dacc928f1b545c44e6760a97baeb93bf4bf4 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -499,15 +499,6 @@ def hidden_resource_icon(resource, css_class: nil)
     end
   end
 
-  def controller_full_path
-    action = case controller.action_name
-             when 'create' then 'new'
-             when 'update' then 'edit'
-             else controller.action_name
-             end
-    "#{controller.controller_path}/#{action}"
-  end
-
   private
 
   def browser_id
diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb
index 556155f3c85972c3607c43d4b10d636e9fd140d1..644fc65427063079efce74859996f1aef9a9be49 100644
--- a/app/helpers/vite_helper.rb
+++ b/app/helpers/vite_helper.rb
@@ -15,4 +15,17 @@ def vite_hmr_websocket_url
   def vite_hmr_http_url
     ViteRuby.env['VITE_HMR_HTTP_URL']
   end
+
+  def vite_page_entrypoint_paths
+    action = case controller.action_name
+             when 'create' then 'new'
+             when 'update' then 'edit'
+             else controller.action_name
+             end
+
+    parts = (controller.controller_path.split('/') << action)
+
+    parts.map
+         .with_index { |part, idx| "pages.#{(parts[0, idx] << part).join('.')}.js" }
+  end
 end
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 4be82f6339ad9223613be36f04df60b25b921703..f6c71fd6fb1994023efa323aa4c04dece5f935a0 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -55,14 +55,9 @@
   = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
 
   - if vite_enabled?
-    %meta{ name: 'controller-path', content: controller_full_path }
     - if Rails.env.development?
       = vite_client_tag
-    = vite_javascript_tag "main"
-    - if Gitlab.ee?
-      = vite_javascript_tag "main_ee"
-    - if Gitlab.jh?
-      = vite_javascript_tag "main_jh"
+    = vite_javascript_tag "main", *vite_page_entrypoint_paths
 
   = yield :page_specific_javascripts
 
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 52cca8fa6dee997a0d1bc50003670834f3193130..fda6bb7b5643ffbae80f77ff11eab0c513ea0a98 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -22,7 +22,6 @@ const BABEL_VERSION = require('@babel/core/package.json').version;
 const BABEL_LOADER_VERSION = require('babel-loader/package.json').version;
 const CompressionPlugin = require('compression-webpack-plugin');
 const CopyWebpackPlugin = require('copy-webpack-plugin');
-const glob = require('glob');
 // eslint-disable-next-line import/no-dynamic-require
 const { VueLoaderPlugin } = require(VUE_LOADER_MODULE);
 // eslint-disable-next-line import/no-dynamic-require
@@ -45,6 +44,7 @@ const {
   GITLAB_WEB_IDE_PUBLIC_PATH,
   copyFilesPatterns,
 } = require('./webpack.constants');
+const { generateEntries } = require('./webpack.helpers');
 
 const createIncrementalWebpackCompiler = require('./helpers/incremental_webpack_compiler');
 const vendorDllHash = require('./helpers/vendor_dll_hash');
@@ -90,10 +90,6 @@ if (WEBPACK_REPORT) {
 
 const devtool = IS_PRODUCTION ? 'source-map' : 'cheap-module-eval-source-map';
 
-let autoEntriesCount = 0;
-let watchAutoEntries = [];
-const defaultEntries = ['./main'];
-
 const incrementalCompiler = createIncrementalWebpackCompiler(
   INCREMENTAL_COMPILER_RECORD_HISTORY,
   INCREMENTAL_COMPILER_ENABLED,
@@ -101,82 +97,6 @@ const incrementalCompiler = createIncrementalWebpackCompiler(
   INCREMENTAL_COMPILER_TTL,
 );
 
-function generateEntries() {
-  // generate automatic entry points
-  const autoEntries = {};
-  const autoEntriesMap = {};
-  const pageEntries = glob.sync('pages/**/index.js', {
-    cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
-  });
-  watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')];
-
-  function generateAutoEntries(entryPath, prefix = '.') {
-    const chunkPath = entryPath.replace(/\/index\.js$/, '');
-    const chunkName = chunkPath.replace(/\//g, '.');
-    autoEntriesMap[chunkName] = `${prefix}/${entryPath}`;
-  }
-
-  pageEntries.forEach((entryPath) => generateAutoEntries(entryPath));
-
-  if (IS_EE) {
-    const eePageEntries = glob.sync('pages/**/index.js', {
-      cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
-    });
-    eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'ee'));
-    watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/'));
-  }
-
-  if (IS_JH) {
-    const eePageEntries = glob.sync('pages/**/index.js', {
-      cwd: path.join(ROOT_PATH, 'jh/app/assets/javascripts'),
-    });
-    eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'jh'));
-    watchAutoEntries.push(path.join(ROOT_PATH, 'jh/app/assets/javascripts/pages/'));
-  }
-
-  const autoEntryKeys = Object.keys(autoEntriesMap);
-  autoEntriesCount = autoEntryKeys.length;
-
-  // import ancestor entrypoints within their children
-  autoEntryKeys.forEach((entry) => {
-    const entryPaths = [autoEntriesMap[entry]];
-    const segments = entry.split('.');
-    while (segments.pop()) {
-      const ancestor = segments.join('.');
-      if (autoEntryKeys.includes(ancestor)) {
-        entryPaths.unshift(autoEntriesMap[ancestor]);
-      }
-    }
-    autoEntries[entry] = defaultEntries.concat(entryPaths);
-  });
-
-  /*
-  If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at
-  the top of the entry in order to ensure that the public path is correctly determined for loading
-  assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath
-
-  Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option
-  Note 2: If you are using web-workers, you might need to reset the public path, see:
-  https://gitlab.com/gitlab-org/gitlab/-/issues/321656
-   */
-
-  const manualEntries = {
-    default: defaultEntries,
-    legacy_sentry: './sentry/legacy_index.js',
-    sentry: './sentry/index.js',
-    performance_bar: './performance_bar/index.js',
-    jira_connect_app: './jira_connect/subscriptions/index.js',
-    sandboxed_mermaid: './lib/mermaid.js',
-    redirect_listbox: './entrypoints/behaviors/redirect_listbox.js',
-    sandboxed_swagger: './lib/swagger.js',
-    super_sidebar: './entrypoints/super_sidebar.js',
-    tracker: './entrypoints/tracker.js',
-    analytics: './entrypoints/analytics.js',
-  };
-
-  return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries));
-}
-
 const alias = {
   // Map Apollo client to apollo/client/core to prevent react related imports from being loaded
   '@apollo/client$': '@apollo/client/core',
@@ -318,12 +238,42 @@ if (USE_VUE3) {
   vueLoaderOptions.compiler = require.resolve('./vue3migration/compiler');
 }
 
+const entriesState = {
+  autoEntriesCount: 0,
+  watchAutoEntries: [],
+};
+const defaultEntries = ['./main'];
+
 module.exports = {
   mode: IS_PRODUCTION ? 'production' : 'development',
 
   context: path.join(ROOT_PATH, 'app/assets/javascripts'),
 
-  entry: generateEntries,
+  entry: () => {
+    /*
+    If you create manual entries, ensure that these import `app/assets/javascripts/webpack.js` right at
+    the top of the entry in order to ensure that the public path is correctly determined for loading
+    assets async. See: https://webpack.js.org/configuration/output/#outputpublicpath
+
+    Note: WebPack 5 has an 'auto' option for the public path which could allow us to remove this option
+    Note 2: If you are using web-workers, you might need to reset the public path, see:
+    https://gitlab.com/gitlab-org/gitlab/-/issues/321656
+     */
+    return {
+      default: defaultEntries,
+      legacy_sentry: './sentry/legacy_index.js',
+      sentry: './sentry/index.js',
+      performance_bar: './performance_bar/index.js',
+      jira_connect_app: './jira_connect/subscriptions/index.js',
+      sandboxed_mermaid: './lib/mermaid.js',
+      redirect_listbox: './entrypoints/behaviors/redirect_listbox.js',
+      sandboxed_swagger: './lib/swagger.js',
+      super_sidebar: './entrypoints/super_sidebar.js',
+      tracker: './entrypoints/tracker.js',
+      analytics: './entrypoints/analytics.js',
+      ...incrementalCompiler.filterEntryPoints(generateEntries({ defaultEntries, entriesState })),
+    };
+  },
 
   output: {
     path: WEBPACK_OUTPUT_PATH,
@@ -522,7 +472,7 @@ module.exports = {
           priority: 20,
           name: 'main',
           chunks: 'initial',
-          minChunks: autoEntriesCount * 0.9,
+          minChunks: entriesState.autoEntriesCount * 0.9,
         }),
         prosemirror: {
           priority: 17,
@@ -728,14 +678,16 @@ module.exports = {
           if (hasMissingNodeModules) compilation.contextDependencies.add(nodeModulesPath);
 
           // watch for changes to automatic entrypoints
-          watchAutoEntries.forEach((watchPath) => compilation.contextDependencies.add(watchPath));
+          entriesState.watchAutoEntries.forEach((watchPath) =>
+            compilation.contextDependencies.add(watchPath),
+          );
 
           // report our auto-generated bundle count
           if (incrementalCompiler.enabled) {
-            incrementalCompiler.logStatus(autoEntriesCount);
+            incrementalCompiler.logStatus(entriesState.autoEntriesCount);
           } else {
             console.log(
-              `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`,
+              `${entriesState.autoEntriesCount} entries from '/pages' automatically added to webpack output.`,
             );
           }
 
diff --git a/config/webpack.helpers.js b/config/webpack.helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..dce1904b29853570d165bf19f6bf03b70ed19953
--- /dev/null
+++ b/config/webpack.helpers.js
@@ -0,0 +1,69 @@
+const path = require('path');
+const glob = require('glob');
+const { IS_EE, IS_JH, ROOT_PATH } = require('./webpack.constants');
+
+function generateEntries({ defaultEntries, entriesState } = { defaultEntries: [] }) {
+  // generate automatic entry points
+  const autoEntries = {};
+  const autoEntriesMap = {};
+  const pageEntries = glob.sync('pages/**/index.js', {
+    cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
+  });
+  if (entriesState) {
+    Object.assign(entriesState, {
+      watchAutoEntries: [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')],
+    });
+  }
+
+  function generateAutoEntries(entryPath, prefix = '.') {
+    const chunkPath = entryPath.replace(/\/index\.js$/, '');
+    const chunkName = chunkPath.replace(/\//g, '.');
+    autoEntriesMap[chunkName] = `${prefix}/${entryPath}`;
+  }
+
+  pageEntries.forEach((entryPath) => generateAutoEntries(entryPath));
+
+  if (IS_EE) {
+    const eePageEntries = glob.sync('pages/**/index.js', {
+      cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
+    });
+    eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'ee'));
+    if (entriesState) {
+      entriesState.watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/'));
+    }
+  }
+
+  if (IS_JH) {
+    const eePageEntries = glob.sync('pages/**/index.js', {
+      cwd: path.join(ROOT_PATH, 'jh/app/assets/javascripts'),
+    });
+    eePageEntries.forEach((entryPath) => generateAutoEntries(entryPath, 'jh'));
+    if (entriesState) {
+      entriesState.watchAutoEntries.push(path.join(ROOT_PATH, 'jh/app/assets/javascripts/pages/'));
+    }
+  }
+
+  const autoEntryKeys = Object.keys(autoEntriesMap);
+  if (entriesState) {
+    Object.assign(entriesState, {
+      autoEntriesCount: autoEntryKeys.length,
+    });
+  }
+
+  // import ancestor entrypoints within their children
+  autoEntryKeys.forEach((entry) => {
+    const entryPaths = [autoEntriesMap[entry]];
+    const segments = entry.split('.');
+    while (segments.pop()) {
+      const ancestor = segments.join('.');
+      if (autoEntryKeys.includes(ancestor)) {
+        entryPaths.unshift(autoEntriesMap[ancestor]);
+      }
+    }
+    autoEntries[entry] = defaultEntries.concat(entryPaths);
+  });
+
+  return autoEntries;
+}
+
+module.exports = { generateEntries };
diff --git a/package.json b/package.json
index 7143a2c965f5f1b03bea5ad4fcef7ebcfce38813..26af246c5dd1b3707d5306c4a1123138624f39d1 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,8 @@
     "swagger:validate": "swagger-cli validate",
     "webpack": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.config.js",
     "webpack-vendor": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.vendor.config.js",
-    "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" NODE_ENV=production webpack --config config/webpack.config.js"
+    "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" NODE_ENV=production webpack --config config/webpack.config.js",
+    "vite-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=8000}\" NODE_ENV=production vite build"
   },
   "dependencies": {
     "@apollo/client": "^3.5.10",
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 18d0e92372dc54a8b3b8307b2c25334ecea8c4ea..dc28ffe657e52f1771b58a410fef01658a4afda7 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -979,38 +979,4 @@ def stub_controller_method(method_name, value)
       end
     end
   end
-
-  describe '#controller_full_path' do
-    let(:path) { 'some_path' }
-    let(:action) { 'show' }
-
-    before do
-      allow(helper.controller).to receive(:controller_path).and_return(path)
-      allow(helper.controller).to receive(:action_name).and_return(action)
-    end
-
-    context 'when is create action' do
-      let(:action) { 'create' }
-
-      it 'transforms to "new" path' do
-        expect(helper.controller_full_path).to eq("#{path}/new")
-      end
-    end
-
-    context 'when is update action' do
-      let(:action) { 'update' }
-
-      it 'transforms to "edit" path' do
-        expect(helper.controller_full_path).to eq("#{path}/edit")
-      end
-    end
-
-    context 'when is show action' do
-      let(:action) { 'show' }
-
-      it 'passes through' do
-        expect(helper.controller_full_path).to eq("#{path}/#{action}")
-      end
-    end
-  end
 end
diff --git a/spec/helpers/vite_helper_spec.rb b/spec/helpers/vite_helper_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fc3f017691f22f63a936443d5cb7992170ac2348
--- /dev/null
+++ b/spec/helpers/vite_helper_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ViteHelper, feature_category: :tooling do
+  describe '#vite_page_entrypoint_path' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:path, :action, :result) do
+      'some_path' | 'create' | %w[pages.some_path.js pages.some_path.new.js]
+      'some_path' | 'new'    | %w[pages.some_path.js pages.some_path.new.js]
+      'some_path' | 'update' | %w[pages.some_path.js pages.some_path.edit.js]
+      'some_path' | 'show'   | %w[pages.some_path.js pages.some_path.show.js]
+      'some/long' | 'path'   | %w[pages.some.js pages.some.long.js pages.some.long.path.js]
+    end
+
+    with_them do
+      before do
+        allow(helper.controller).to receive(:controller_path).and_return(path)
+        allow(helper.controller).to receive(:action_name).and_return(action)
+      end
+
+      it { expect(helper.vite_page_entrypoint_paths).to eq(result) }
+    end
+  end
+end
diff --git a/vite.config.js b/vite.config.js
index f1d3342c719081b0a06a22075121d1afa3c72de3..7a0e78ced0391bc95c82cf58ee8d671dbdb416a0 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -10,6 +10,7 @@ import chokidar from 'chokidar';
 import globby from 'globby';
 import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
 import webpackConfig from './config/webpack.config';
+import { generateEntries } from './config/webpack.helpers';
 import {
   IS_EE,
   IS_JH,
@@ -43,6 +44,15 @@ const emptyComponent = path.resolve(javascriptsPath, 'vue_shared/components/empt
 
 const [rubyPlugin, ...rest] = RubyPlugin();
 
+const comment = '/* this is a virtual module used by Vite, it exists only in dev mode */\n';
+
+const virtualEntrypoints = Object.entries(generateEntries()).reduce((acc, [entryName, imports]) => {
+  const modulePath = imports[imports.length - 1];
+  const importPath = modulePath.startsWith('./') ? `~/${modulePath.substring(2)}` : modulePath;
+  acc[`${entryName}.js`] = `${comment}/* ${modulePath} */ import '${importPath}';\n`;
+  return acc;
+}, {});
+
 // We can't use regular 'resolve' which points to sourceCodeDir in vite.json
 // Because we need for '~' alias to resolve to app/assets/javascripts
 // We can't use javascripts folder in sourceCodeDir because we also need to resolve other assets
@@ -163,6 +173,22 @@ function viteCopyPlugin({ patterns }) {
   };
 }
 
+const entrypointsDir = '/javascripts/entrypoints/';
+const pageEntrypointsPlugin = {
+  name: 'page-entrypoints',
+  load(id) {
+    if (!id.startsWith('pages.')) {
+      return undefined;
+    }
+    return virtualEntrypoints[id] ?? `/* doesn't exist */`;
+  },
+  resolveId(source) {
+    const fixedSource = source.replace(entrypointsDir, '');
+    if (fixedSource.startsWith('pages.')) return { id: fixedSource };
+    return undefined;
+  },
+};
+
 export default defineConfig({
   cacheDir: path.resolve(__dirname, 'tmp/cache/vite'),
   resolve: {
@@ -181,6 +207,7 @@ export default defineConfig({
     ],
   },
   plugins: [
+    pageEntrypointsPlugin,
     viteCSSCompilerPlugin({ shouldWatch: viteGDKConfig.hmr !== null }),
     viteTailwindCompilerPlugin({ shouldWatch: viteGDKConfig.hmr !== null }),
     viteCopyPlugin({
@@ -238,4 +265,12 @@ export default defineConfig({
   worker: {
     format: 'es',
   },
+  build: {
+    rollupOptions: {
+      input: Object.keys(virtualEntrypoints).reduce((acc, value) => {
+        acc[value] = value;
+        return acc;
+      }, {}),
+    },
+  },
 });