Close PHASE 2 with the ecosystem foundation: learn why lazy.nvim became the de-facto plugin manager standard, write the bootstrap script in init.lua, organize modular plugin specs in lua/plugins/, and master the :Lazy UI for install, update, clean, and performance profiling.

After building autocommands, custom commands, and filetype detection in episode 8 — you can now make Neovim work reactively and contextually. But let's be honest for a moment: there is one thing you cannot do with purely manual config, namely adding features that do not exist in Neovim core. A VS Code-style fuzzy finder, Treesitter-based syntax highlighting, full LSP integration, a beautiful statusline — all of them require plugins.
And in the modern Neovim world, plugins are never installed manually with git clone. There is a manager for that — and in this episode, we will build the foundation that will accompany the rest of the series: lazy.nvim.
Why does this topic close PHASE 2 (Windows, Buffers & Lua Configuration) perfectly? Because lazy.nvim is living proof of everything we learned: it is written in Lua, configured with Lua tables, loaded via require modules, and leverages autocommand events for lazy-loading. If you understood the previous four episodes, then this episode feels like a reunion — all the concepts meet in one place.
For those of you working as DevOps/SRE, this understanding is also directly valuable: the lockfile principle (lazy-lock.json), reproducible installs, and dependency management in lazy.nvim are the same concepts you manage in package.json, go.mod, requirements.txt, or lockfiles in your CI pipeline.
lazy.nvimThe history of Neovim plugin managers runs in step with the editor's own evolution:
| Feature | lazy.nvim | packer.nvim | vim-plug |
|---|---|---|---|
| Lazy-loading (event/cmd/ft/keys) | ✔ 4 mechanisms | ✔ | Limited |
| Reproducible lockfile | ✔ lazy-lock.json | ✔ | ✘ |
| Modern management UI | ✔ Beautiful TUI | Simple | CLI |
Automatic per-plugin opts setup | ✔ | Partial | ✘ |
| Modular plugin specs (folder) | ✔ | ✔ | ✘ |
| Startup performance profile | ✔ :Lazy profile | ✘ | ✘ |
| Development status | Active | Archived | Maintenance |
Note
Why did lazy.nvim win outright? Because it solves the problem that bothers Neovim users the most: startup time. A config with 50+ plugins all loaded at startup can take 500ms–1s just to open Neovim. With lazy-loading, each plugin loads only when it is actually needed — the result is Neovim opening in under 50ms even with 100 plugins. This is what makes it not just a manager, but a paradigm shifter.
lazy.nviminit.luaBecause lazy.nvim is itself a plugin, it must be installed first — and the most elegant way is automatic bootstrap: a small script in init.lua that clones lazy.nvim into the Neovim data directory if it does not exist yet. Here is how:
-- 1. Tentukan path lazy.nvim di direktori data Neovim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
-- 2. Clone jika belum ada
if not (vim.uv or vim.loop).fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
-- 3. Tambahkan lazy.nvim ke runtimepath
vim.opt.rtp:prepend(lazypath)
-- 4. Mulai lazy.nvim dan muat semua spec di folder lua/plugins/
require("lazy").setup("plugins")Tip
Two important technical details from the script above. First, vim.fn.stdpath("data") returns Neovim's data storage location (usually ~/.local/share/nvim/) — this is the right place for plugins, separate from the config directory. Second, vim.uv.fs_stat checks whether a folder exists; vim.uv is the new API (Neovim 0.10+), while vim.loop is its old name — writing (vim.uv or vim.loop) keeps the script compatible across versions. Finally, --filter=blob:none makes the clone much faster because it only fetches Git metadata.
lua/plugins/*.luaThe most important line is require("lazy").setup("plugins"). When the given argument is a directory ("plugins"), lazy.nvim automatically loads all .lua files inside lua/plugins/ — and each file returns one or more plugin specs.
~/.config/nvim/
├── init.lua
└── lua/
├── config/
│ ├── options.lua
│ ├── keymaps.lua
│ ├── autocmds.lua
│ └── lazy.lua
└── plugins/
├── telescope.lua # satu file = satu plugin
├── treesitter.lua
├── lsp.lua
├── cmp.lua
└── ...Note
The "one file per plugin" pattern is not a hard rule, but a convention that keeps your config readable and maintainable. When a plugin misbehaves, you know exactly which file to open. When you want to disable a plugin, just comment out its return or set { enabled = false } in the spec. Exactly like splitting services into microservices — modular, isolated, with clear responsibilities.
Every file in lua/plugins/ returns a spec — a Lua table describing which plugin to install, where from, when to load, and how to configure it. Here is a complete spec example for telescope.nvim (the fuzzy finder we will discuss in detail in episode 11):
return {
-- 1. Sumber plugin (nama repo GitHub)
"nvim-telescope/telescope.nvim",
tag = "0.1.8", -- ikat versi stabil tertentu
-- 2. Dependensi: dimuat bersama plugin ini
dependencies = { "nvim-lua/plenary.nvim" },
-- 3. Lazy-loading: muat saat command :Telescope dipanggil
cmd = "Telescope",
-- 4. Lazy-loading: muat saat tombol berikut ditekan
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Cari file" },
{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Cari teks (grep)" },
{ "<leader>fb", "<cmd>Telescope buffers<CR>", desc = "Daftar buffer" },
},
-- 5. Konfigurasi: tabel ini diteruskan ke fungsi setup() plugin
opts = {
defaults = {
prompt_prefix = " ",
sorting_strategy = "ascending",
layout_config = { horizontal = { prompt_position = "top" } },
},
},
}Let's break down each part of the spec:
user/repo). This is the only part that is truly required.dependencies — other plugins that must load before/with this plugin. plenary.nvim is Telescope's supporting library.cmd — the list of commands that trigger loading. As long as you have not typed :Telescope, this plugin is not loaded.keys — the list of shortcuts that trigger loading while also defining the keymaps. The format is the same as vim.keymap.set we learned in episode 7.opts — a configuration table automatically passed to require("telescope").setup(opts). This pattern eliminates manual boilerplate.Tip
Notice that keys and opts complement each other perfectly. The keymaps you define in keys become active together with the plugin loading (not waiting for the plugin to load, thanks to lazy.nvim's "lazy keys" mechanism). Meanwhile, opts merges into the plugin setup. With these two features, you do not need to write separate keymaps in keymaps.lua for plugins — everything lives in one spec.
This is the heart of lazy.nvim's performance. Four main mechanisms for delaying plugin loading until it is truly needed:
| Option | Mechanism | Example Usage |
|---|---|---|
event | Load when a specific event occurs | event = "BufReadPre" (treesitter), event = "VeryLazy" (load after startup finishes) |
cmd | Load when a command runs | cmd = "Telescope" (telescope), cmd = "Mason" (mason) |
ft | Load when a specific filetype is opened | ft = { "markdown" } (markdown plugin), ft = { "go" } (Go plugin) |
keys | Load when a key combination is pressed | keys = { "<leader>ff" } (telescope) |
Here is a real example for several plugins we will install in the coming episodes:
-- Plugin Tree-sitter: dimuat saat buffer dibaca
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
event = { "BufReadPre", "BufNewFile" },
main = "nvim-treesitter.configs",
opts = { highlight = { enable = true } },
}
-- Plugin Mason (LSP installer): dimuat saat :Mason dipanggil
return {
"williamboman/mason.nvim",
cmd = "Mason",
opts = {},
}
-- Colorscheme: dimuat paling awal dengan priority tinggi
return {
"catppuccin/nvim",
name = "catppuccin",
priority = 1000, -- dimuat sebelum plugin lain
lazy = false, -- muat di startup (bukan lazy)
opts = { flavour = "mocha" },
}Important
Not every plugin must be lazy-loaded. Plugins that determine the initial appearance (colorscheme, statusline) and plugins that act as the "muscle" in every buffer (treesitter, compiler) should actually load earlier. For the colorscheme, use priority = 1000 and lazy = false so the colors do not "flash" at startup. The rule of thumb: load immediately what is needed from the first second, delay the rest. Do not delay everything just for startup numbers — measure the real benefit.
:Lazy UIOnce the setup is done, open Neovim and type :Lazy. You will see a beautiful TUI: a list of all plugins with their install status, a search box, and action buttons at the bottom.
Lazy.nvim
A plugin manager for Neovim
⚡ Install all missing plugins
⬆ Update plugins
🗑 Clean unused plugins
⏱ Profile startup time
Plugins:
telescope.nvim installed v0.1.8
plenary.nvim installed v2.0.0
nvim-treesitter installed
catppuccin installedThe main commands you will use day to day:
| Command | Function |
|---|---|
:Lazy | Open the management UI |
:Lazy install | Install all plugins that are missing |
:Lazy update | Update all plugins to the latest versions |
:Lazy sync | Install + update + clean in one command |
:Lazy clean | Remove plugins not in the specs |
:Lazy check | Check for plugins with outdated versions |
:Lazy profile | Show the startup time profile per plugin |
:Lazy reload | Reload plugins without restarting |
:Lazy locks | Show the lockfile contents |
Inside the :Lazy UI, navigation is also easy: I to install, U to update, C to clean, p for profile, and Enter to view plugin details. The shortcuts shown at the top of the TUI always display the available keys.
lazy-lock.jsonOn first install, lazy.nvim creates a lazy-lock.json file in the config directory:
{
"telescope.nvim": { "commit": "3b8a7f2..." },
"plenary.nvim": { "commit": "d6c8a3e..." },
"nvim-treesitter": { "commit": "f2e4b1a..." }
}Warning
Commit the lazy-lock.json file to your dotfiles repo. The lockfile is a version contract: it pins every plugin's commit so that installing on another machine (or in CI) produces identical versions to your machine. Without a lockfile, two people on the same team can have different plugin versions — and that difference is the source of the sneakiest bugs ("why does it error on mine but not theirs?"). This principle is identical to the package-lock.json or bun.lock you manage in projects — you are already familiar.
The full cycle of adding a plugin with lazy.nvim:
# 1. Buat spec file baru di lua/plugins/
# contoh: lua/plugins/telescope.lua
# 2. Restart Neovim (atau :Lazy reload)
nvim
# 3. Install plugin yang baru ditambahkan
:Lazy install
# 4. Periksa apakah semuanya bersih
:LazyTip
After changing specs, do not always restart manually. :Lazy reload plugins reloads all plugin specs without closing the editor. And if you add a new plugin, :Lazy sync is a single command that installs, updates, and cleans in one go — enough for most cases.
lazy.nvim Pitfallslua/plugins/foo.lua does not automatically install the plugin. Run :Lazy install or :Lazy sync after adding a new file.require("lazy").setup("plugins") cannot find the folder, lazy.nvim stays silent without installing anything (or errors with "module not found"). Make sure the folder really is named lua/plugins/ relative to init.lua."telescope.nvim" (instead of "nvim-telescope/telescope.nvim") fails because lazy.nvim looks for the telescope.nvim/telescope.nvim repo. Always write the full owner/repo.dependencies. Plugins that require supporting libraries (for example Telescope needs plenary.nvim) will error on load. Add them to dependencies.lazy-lock.json. Reproducibility is lost — plugin versions differ on each machine. Always commit.priority and lazy = false for fundamental plugins.:Lazy profile and consider lazy-loading for rarely used plugins.opts but not seeing the effect. opts only applies when the plugin is set up. Make sure the table format is correct and restart / :Lazy reload after changing.Note
Diagnose problems quickly: startup errors usually appear as red text with the name of the problematic plugin. Check :Lazy profile to see which plugin is slow, and :Lazy to see install status. If a plugin fails completely, comment out its spec (or set { enabled = false }) then :Lazy sync to continue — the config does not need to be crippled because of one broken plugin.
In episode 9 we closed PHASE 2 by building the ecosystem foundation that will be used for the rest of the series. You understand why lazy.nvim became the de-facto standard — event-based lazy-loading, a lockfile for stability, and a beautiful management UI. You wrote the bootstrap script in init.lua that clones and loads lazy.nvim automatically. You organized modular plugin specs in lua/plugins/*.lua with dependencies, cmd, keys, and opts. Finally, you mastered the :Lazy UI for install, update, clean, sync, and profile.
Key points to take with you:
init.lua, then require("lazy").setup("plugins").lua/plugins/*.lua file = one plugin, and every file returns a spec.event, cmd, ft, keys — choose according to when the plugin is needed.lazy-lock.json must be committed for cross-machine stability.:Lazy sync is one command for install + update + clean.This is the last episode of PHASE 2. From here, you have built a Neovim that is comfortable (options & keymaps), reactive (autocommands & custom commands), and ready to accept plugins (lazy.nvim). In PHASE 3, you start beautifying the editor: in episode 10 we will cover Visual Customization, Colorscheme & Statusline — installing modern themes like Tokyo Night or Catppuccin, building an informative statusline with lualine.nvim, and adding a bufferline and indent guides. Imagine the result: your Neovim will look as beautiful as a commercial IDE, without the heavy startup load. Stay motivated!