Neovim 0.12 ships a built-in package manager, vim.pack. Here's how I migrated my full config away from lazy.nvim, what the new patterns look like, and where I got caught out.
Neovim 0.12 ships vim.pack, a built-in package manager. I'd been on lazy.nvim for a couple of years and it served me well, but the appeal of dropping a bootstrapped dependency and working with something built into the editor was enough to make me try the migration.
Here's how it went.
The core API is vim.pack.add(). You pass it a list of plugin specs:
vim.pack.add({
{ src = 'https://github.com/folke/tokyonight.nvim' },
{ src = 'https://github.com/lewis6991/gitsigns.nvim' },
})That's it. No plugin manager bootstrap, no require('lazy').setup(), no separate spec files with return {}. Just call vim.pack.add() in your config and then require() the plugin to configure it.
A lock file (nvim-pack-lock.json) is written automatically with pinned commit revisions, equivalent to lazy-lock.json.
Most plugins are a straight swap. A lazy.nvim spec like:
return {
'lewis6991/gitsigns.nvim',
config = function()
require('gitsigns').setup({ ... })
end,
}Becomes:
vim.pack.add({
{ src = 'https://github.com/lewis6991/gitsigns.nvim' },
})
require('gitsigns').setup({ ... })The config callback is gone. You just call setup inline after vim.pack.add(). There's no magic here; the plugin is available immediately after the add call.
For plugins that publish releases, you can pin with vim.version.range():
{ src = 'https://github.com/saghen/blink.cmp', version = vim.version.range('*') },
{ src = 'https://github.com/echasnovski/mini.pairs', version = vim.version.range('*') },For plugins that use branch names instead of tags (telescope, nvim-treesitter), pass a string:
{ src = 'https://github.com/nvim-telescope/telescope.nvim', version = 'master' },
{ src = 'https://github.com/nvim-treesitter/nvim-treesitter', version = 'main' },lazy.nvim had a build key. vim.pack uses a PackChanged autocmd instead. The event fires after a plugin is installed or updated, and ev.data.active tells you whether it's newly available.
For telescope-fzf-native:
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(ev)
if ev.data.spec.name == 'telescope-fzf-native.nvim' then
if not ev.data.active then vim.cmd.packadd('telescope-fzf-native.nvim') end
vim.fn.system('make -C ' .. ev.data.path)
end
end,
})
if vim.fn.executable('make') == 1 then
vim.pack.add({ { src =
For peek.nvim (which needs a Deno build):
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(ev)
if ev.data.spec.name == 'peek.nvim' then
if not ev.data.active then vim.cmd.packadd('peek.nvim') end
vim.fn.jobstart('deno task --quiet build:fast', { cwd = ev.data.path })
end
end,
})The pattern is consistent: register the autocmd before calling vim.pack.add(), run your build inside the callback.
vim.pack has no built-in lazy loading. lazy.nvim's event, cmd, ft and keys keys don't exist here.
For most plugins this doesn't matter. Startup is fast enough without it. For genuinely heavy plugins, you handle it yourself with autocmds. I load the Roslyn LSP only when a C# file is opened:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'cs',
once = true,
callback = function()
vim.pack.add({
{ src = 'https://github.com/seblj/roslyn.nvim' },
})
vim.lsp.config('roslyn', { ... })
vim.lsp.enable('roslyn')
end,
})once = true means the autocmd fires once and cleans up. This is more explicit than lazy.nvim's ft key, but it's also just a plain Neovim autocmd with nothing new to learn.
lazy.nvim's dependencies key ensured plugins loaded in the right order. vim.pack doesn't have this. The fix is straightforward: add dependencies before the plugins that need them in the same vim.pack.add() call, or in an earlier one.
For example, plenary.nvim is added before telescope.nvim:
vim.pack.add({
{ src = 'https://github.com/nvim-lua/plenary.nvim' },
{ src = 'https://github.com/nvim-telescope/telescope.nvim', version = 'master' },
...
})After migrating I hit a crash in telescope's previewer. Neovim 0.12 changed telescope.state to use a weak-value table (__mode = "kv"), which meant the layout object could be garbage collected between the guard check and the preview function executing.
I handed this to Claude to debug. It identified the root cause and the fix: pin layout as a local to prevent collection.
local Picker = require('telescope.pickers')._Picker
local ts_state = require('telescope.state')
local orig_refresh = Picker.refresh_previewer
Picker.refresh_previewer = function(self)
local status = ts_state.get_status(self.prompt_bufnr)
local _layout = status.layout
if not _layout then return end
orig_refresh(self)
endWorth knowing if you see telescope crashing on preview, but not something I would have tracked down quickly on my own.
This one caught me off guard. With lazy.nvim, :Lazy update pulls the latest commit for each plugin and shows you a diff in a floating window. vim.pack has no UI like that. Updates are applied via :pack update, which outputs to the pager. The change isn't written until you explicitly confirm it.
It's a more deliberate workflow, closer to running npm update and committing the lock file than to lazy.nvim's one-shot dashboard update. If you're used to lazy.nvim keeping everything quietly fresh, it's easy to not realise your plugins are sitting on old revisions. Build the habit of running :pack update periodically and committing the updated nvim-pack-lock.json.
:Lazy dashboard is genuinely useful for profiling startup time. vim.pack has no equivalent yet.vim.pack.add() and then standard Lua.If your lazy.nvim config is working and you have a lot of event, cmd or keys lazy-loading, staying put is reasonable. vim.pack doesn't match lazy.nvim feature-for-feature yet.
If your config is relatively straightforward and you want to reduce moving parts, the migration is low-risk. Most plugins are a find-and-replace. The only real work is rewriting build steps and any lazy-loading you care about.