diff --git a/docs/src/config/index.md b/docs/src/config/index.md
index 1a60431a..851f28f0 100644
--- a/docs/src/config/index.md
+++ b/docs/src/config/index.md
@@ -285,6 +285,18 @@ You can customize this behavior using the following options.
 
   Allows to skip Vite build output from logs, to keep the noise down.
 
+### packageManager
+
+- **Default:** auto-detected based on existing lockfiles, otherwise `"npm"`
+- **Env Var:** `VITE_RUBY_PACKAGE_MANAGER`
+
+  Allows to specify which package manager to use, such as:
+
+  - `npm`
+  - `pnpm`
+  - `yarn`
+  - `bun` (experimental)
+
 ### root
 
 - **Default:** `Rails.root`
diff --git a/vite-plugin-ruby/default.vite.json b/vite-plugin-ruby/default.vite.json
index c28f575a..947206f2 100644
--- a/vite-plugin-ruby/default.vite.json
+++ b/vite-plugin-ruby/default.vite.json
@@ -7,6 +7,7 @@
   "publicOutputDir": "vite",
   "configPath": "config/vite.json",
   "devServerConnectTimeout": 0.01,
+  "packageManager": null,
   "publicDir": "public",
   "entrypointsDir": "entrypoints",
   "sourceCodeDir": "app/frontend",
diff --git a/vite_ruby/default.vite.json b/vite_ruby/default.vite.json
index c28f575a..947206f2 100644
--- a/vite_ruby/default.vite.json
+++ b/vite_ruby/default.vite.json
@@ -7,6 +7,7 @@
   "publicOutputDir": "vite",
   "configPath": "config/vite.json",
   "devServerConnectTimeout": 0.01,
+  "packageManager": null,
   "publicDir": "public",
   "entrypointsDir": "entrypoints",
   "sourceCodeDir": "app/frontend",
diff --git a/vite_ruby/lib/tasks/vite.rake b/vite_ruby/lib/tasks/vite.rake
index f02337c8..65121d98 100644
--- a/vite_ruby/lib/tasks/vite.rake
+++ b/vite_ruby/lib/tasks/vite.rake
@@ -43,10 +43,13 @@ namespace :vite do
   desc 'Ensure build dependencies like Vite are installed before bundling'
   task :install_dependencies do
     install_env_args = ENV['VITE_RUBY_SKIP_INSTALL_DEV_DEPENDENCIES'] == 'true' ? {} : { 'NODE_ENV' => 'development' }
-    cmd = ViteRuby.commands.legacy_npm_version? ? 'npx ci --yes' : 'npx --yes ci'
-    result = system(install_env_args, cmd)
-    # Fallback to `yarn` if `npx` is not available.
-    system(install_env_args, 'yarn install --frozen-lockfile') if result.nil?
+
+    install_cmd = case (pkg = ViteRuby.config.package_manager)
+    when 'npm' then 'npm ci'
+    else "#{ pkg } install --frozen-lockfile"
+    end
+
+    system(install_env_args, install_cmd)
   end
 
   desc "Provide information on ViteRuby's environment"
diff --git a/vite_ruby/lib/vite_ruby/cli/install.rb b/vite_ruby/lib/vite_ruby/cli/install.rb
index 1d8335ba..cb9fa073 100644
--- a/vite_ruby/lib/vite_ruby/cli/install.rb
+++ b/vite_ruby/lib/vite_ruby/cli/install.rb
@@ -6,7 +6,11 @@
 class ViteRuby::CLI::Install < Dry::CLI::Command
   desc 'Performs the initial configuration setup to get started with Vite Ruby.'
 
-  def call(**)
+  option(:package_manager, values: %w[npm pnpm yarn bun], aliases: %w[package-manager with], desc: 'The package manager to use when installing JS dependencies.')
+
+  def call(package_manager: nil, **)
+    ENV['VITE_RUBY_PACKAGE_MANAGER'] ||= package_manager if package_manager
+
     $stdout.sync = true
 
     say 'Creating binstub'
@@ -93,8 +97,7 @@ def install_js_dependencies
       FileUtils.mv root.join('vite.config.ts'), root.join('vite.config.mts'), force: true, verbose: true
     end
 
-    deps = js_dependencies.join(' ')
-    run_with_capture("#{ npm_install } -D #{ deps }", stdin_data: "\n")
+    install_js_packages js_dependencies.join(' ')
   end
 
   # Internal: Adds compilation output dirs to git ignore.
@@ -128,12 +131,8 @@ def run_with_capture(*args, **options)
     end
   end
 
-  # Internal: Support all popular package managers.
-  def npm_install
-    return 'yarn add' if root.join('yarn.lock').exist?
-    return 'pnpm install' if root.join('pnpm-lock.yaml').exist?
-
-    'npm install'
+  def install_js_packages(deps)
+    run_with_capture("#{ config.package_manager } add -D #{ deps }", stdin_data: "\n")
   end
 
   # Internal: Avoid printing warning about missing vite.json, we will create one.
diff --git a/vite_ruby/lib/vite_ruby/cli/upgrade_packages.rb b/vite_ruby/lib/vite_ruby/cli/upgrade_packages.rb
index 931f9c61..2945e8b7 100644
--- a/vite_ruby/lib/vite_ruby/cli/upgrade_packages.rb
+++ b/vite_ruby/lib/vite_ruby/cli/upgrade_packages.rb
@@ -5,7 +5,6 @@ class ViteRuby::CLI::UpgradePackages < ViteRuby::CLI::Install
 
   def call(**)
     say 'Upgrading npm packages'
-    deps = js_dependencies.join(' ')
-    run_with_capture("#{ npm_install } -D #{ deps }")
+    install_js_packages js_dependencies.join(' ')
   end
 end
diff --git a/vite_ruby/lib/vite_ruby/commands.rb b/vite_ruby/lib/vite_ruby/commands.rb
index c4d94043..e1c0249c 100644
--- a/vite_ruby/lib/vite_ruby/commands.rb
+++ b/vite_ruby/lib/vite_ruby/commands.rb
@@ -109,11 +109,11 @@ def print_info
         $stdout.puts "#{ framework }: #{ Gem.loaded_specs[framework]&.version }"
       end
 
-      $stdout.puts "node: #{ `node --version` }"
-      $stdout.puts "npm: #{ `npm --version` }"
-      $stdout.puts "yarn: #{ `yarn --version` rescue nil }"
-      $stdout.puts "pnpm: #{ `pnpm --version` rescue nil }"
       $stdout.puts "ruby: #{ `ruby --version` }"
+      $stdout.puts "node: #{ `node --version` }"
+
+      pkg = config.package_manager
+      $stdout.puts "#{ pkg }: #{ `#{ pkg } --version` rescue nil }"
 
       $stdout.puts "\n"
       packages = `npm ls vite vite-plugin-ruby`
diff --git a/vite_ruby/lib/vite_ruby/config.rb b/vite_ruby/lib/vite_ruby/config.rb
index bcc64c7c..7ef4afc0 100644
--- a/vite_ruby/lib/vite_ruby/config.rb
+++ b/vite_ruby/lib/vite_ruby/config.rb
@@ -96,10 +96,11 @@ def within_root(&block)
   def coerce_values(config)
     config['mode'] = config['mode'].to_s
     config['port'] = config['port'].to_i
-    config['root'] = Pathname.new(config['root'])
-    config['build_cache_dir'] = config['root'].join(config['build_cache_dir'])
-    config['ssr_output_dir'] = config['root'].join(config['ssr_output_dir'])
+    config['root'] = root = Pathname.new(config['root'])
+    config['build_cache_dir'] = root.join(config['build_cache_dir'])
+    config['ssr_output_dir'] = root.join(config['ssr_output_dir'])
     coerce_booleans(config, 'auto_build', 'hide_build_console_output', 'https', 'skip_compatibility_check', 'skip_proxy')
+    config['package_manager'] ||= detect_package_manager(root)
   end
 
   # Internal: Coerces configuration options to boolean.
@@ -107,6 +108,15 @@ def coerce_booleans(config, *names)
     names.each { |name| config[name] = [true, 'true'].include?(config[name]) }
   end
 
+  def detect_package_manager(root)
+    return 'npm' if root.join('package-lock.json').exist?
+    return 'pnpm' if root.join('pnpm-lock.yaml').exist?
+    return 'bun' if root.join('bun.lockb').exist?
+    return 'yarn' if root.join('yarn.lock').exist?
+
+    'npm'
+  end
+
   def initialize(attrs)
     @config = attrs.tap { |config| coerce_values(config) }.freeze
     ViteRuby::CompatibilityCheck.verify_plugin_version(root) unless skip_compatibility_check
@@ -189,6 +199,7 @@ def config_from_file(path, mode:)
 
   # Internal: If any of these files is modified the build won't be skipped.
   DEFAULT_WATCHED_PATHS = %w[
+    bun.lockb
     package-lock.json
     package.json
     pnpm-lock.yaml
@@ -196,8 +207,8 @@ def config_from_file(path, mode:)
     tailwind.config.js
     vite.config.js
     vite.config.mjs
-    vite.config.ts
     vite.config.mts
+    vite.config.ts
     windi.config.ts
     yarn.lock
   ].freeze
diff --git a/vite_ruby/lib/vite_ruby/runner.rb b/vite_ruby/lib/vite_ruby/runner.rb
index b12f13eb..60f2830e 100644
--- a/vite_ruby/lib/vite_ruby/runner.rb
+++ b/vite_ruby/lib/vite_ruby/runner.rb
@@ -28,27 +28,26 @@ def run(argv, exec: false)
   # Internal: Returns an Array with the command to run.
   def command_for(args)
     [config.to_env(env)].tap do |cmd|
-      npx_options, vite_args = args.partition { |arg| arg.start_with?('--node-options') }
-      cmd.push(*vite_executable)
-
-      # NOTE: Vite will parse args, so we need to disambiguate and pass them to
-      # `npx` before specifying the `vite` executable.
-      cmd.insert(-2, *npx_options) unless npx_options.empty?
-
+      exec_args, vite_args = args.partition { |arg| arg.start_with?('--node-options') }
+      cmd.push(*vite_executable(*exec_args))
       cmd.push(*vite_args)
       cmd.push('--mode', config.mode) unless args.include?('--mode') || args.include?('-m')
     end
   end
 
   # Internal: Resolves to an executable for Vite.
-  def vite_executable
+  def vite_executable(*exec_args)
     bin_path = config.vite_bin_path
     return [bin_path] if bin_path && File.exist?(bin_path)
 
-    if config.root.join('yarn.lock').exist?
-      %w[yarn vite]
-    else
-      %w[npx vite]
+    x = case config.package_manager
+    when 'npm' then %w[npx]
+    when 'pnpm' then %w[pnpm exec]
+    when 'bun' then %w[bun x]
+    when 'yarn' then %w[yarn]
+    else raise ArgumentError, "Unknown package manager #{ config.package_manager.inspect }"
     end
+
+    [*x, *exec_args, 'vite']
   end
 end