Bootstrapping Neovim with lazy.nvim and Mason, A Backend Dev Setup
TL;DR — A working backend Neovim setup needs
lazy.nvimas the plugin manager,mason.nvimfor tool installation, andnvim-lspconfigto wire language servers. Everything else is preference. Total bootstrap time, around 20 minutes for a clean machine.
I’ve rebuilt my Neovim config from scratch maybe five times in the last three years. The configuration that survives in April 2024 is small, declarative, and boring. That’s the goal. A bootstrap shouldn’t be a personality.
This post walks through the exact files I drop on a new machine. It targets backend developers running Go, Python, and TypeScript. The same scaffold works for Rust, Java, or anything Mason supports, you just swap the server list.
I’ll skip the “what is Neovim” preamble. If you’re reading this, you’ve already decided to switch. Let’s get the editor running.
The Directory Layout
Neovim looks for ~/.config/nvim/init.lua first, then loads anything in ~/.config/nvim/lua/ that you require(). The layout I use is:
~/.config/nvim/
├── init.lua
├── lua/
│ ├── options.lua
│ ├── keymaps.lua
│ └── plugins/
│ ├── init.lua
│ ├── lsp.lua
│ ├── telescope.lua
│ └── ui.lua
└── lazy-lock.json
The split exists because init.lua becomes unreadable past ~150 lines. Splitting by concern (options, keymaps, plugin specs) is the only structure that survives a year of edits.
init.lua is tiny. It does three things: load options and keymaps, bootstrap lazy.nvim, and hand off plugin loading.
-- ~/.config/nvim/init.lua
vim.g.mapleader = ' '
vim.g.maplocalleader = ' '
require('options')
require('keymaps')
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not 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
vim.opt.rtp:prepend(lazypath)
require('lazy').setup('plugins', {
change_detection = { notify = false },
})
Setting mapleader before lazy.nvim loads is important. Plugins read it when they register keymaps. Set it late and half your shortcuts bind to the wrong key.
Bootstrap lazy.nvim Once
The lazypath block above clones lazy.nvim into the data directory the first time Neovim starts. After that, lazy.nvim manages itself and pins versions in lazy-lock.json, which you should absolutely commit to your dotfiles repo.
The require('lazy').setup('plugins', ...) call tells lazy to load every file under lua/plugins/ and treat the returned tables as plugin specs. That gives you the modular layout above without any extra wiring.
A plugin spec file looks like this:
-- lua/plugins/ui.lua
return {
{ 'catppuccin/nvim', name = 'catppuccin', priority = 1000,
config = function() vim.cmd.colorscheme('catppuccin-mocha') end },
{ 'nvim-lualine/lualine.nvim',
dependencies = { 'nvim-tree/nvim-web-devicons' },
opts = { options = { theme = 'catppuccin', globalstatus = true } } },
{ 'lewis6991/gitsigns.nvim', event = 'BufReadPre', opts = {} },
}
The priority = 1000 on the colorscheme matters. Without it, lualine loads first and renders in default colors for a fraction of a second on startup. Annoying.
Mason for Tooling
Mason solves a problem nobody wants to solve manually, namely installing language servers, formatters, and linters across machines. It downloads binaries to a Neovim-managed directory and adds them to the editor’s PATH.
The relevant spec:
-- lua/plugins/lsp.lua
return {
{ 'williamboman/mason.nvim',
cmd = 'Mason',
opts = { ui = { border = 'rounded' } } },
{ 'williamboman/mason-lspconfig.nvim',
dependencies = { 'mason.nvim', 'neovim/nvim-lspconfig' },
opts = {
ensure_installed = { 'gopls', 'pyright', 'tsserver', 'lua_ls' },
automatic_installation = true,
} },
{ 'neovim/nvim-lspconfig',
event = { 'BufReadPre', 'BufNewFile' },
config = function()
local lspconfig = require('lspconfig')
local on_attach = function(_, bufnr)
local map = function(k, fn) vim.keymap.set('n', k, fn, { buffer = bufnr }) end
map('gd', vim.lsp.buf.definition)
map('gr', vim.lsp.buf.references)
map('K', vim.lsp.buf.hover)
map('<leader>rn', vim.lsp.buf.rename)
map('<leader>ca', vim.lsp.buf.code_action)
end
local capabilities = require('cmp_nvim_lsp').default_capabilities()
for _, server in ipairs({ 'gopls', 'pyright', 'tsserver', 'lua_ls' }) do
lspconfig[server].setup({
on_attach = on_attach,
capabilities = capabilities,
})
end
end },
}
A few details that are easy to miss. mason-lspconfig bridges mason (which knows about binaries) and lspconfig (which knows about Neovim’s LSP client). They have different names for the same servers, and mason-lspconfig translates between them. Don’t try to wire mason directly to lspconfig, it’ll work for a month and then break in a confusing way.
cmp_nvim_lsp.default_capabilities() advertises completion features to the server. If you skip it, you’ll get LSP completion but it’ll be missing snippet support and a few other niceties.
Completion That Doesn’t Fight You
nvim-cmp is the default completion engine. The config is verbose but most of it is the keymap, which you’ll want to customize:
-- lua/plugins/cmp.lua
return {
{ 'hrsh7th/nvim-cmp',
event = 'InsertEnter',
dependencies = {
'hrsh7th/cmp-nvim-lsp',
'hrsh7th/cmp-buffer',
'hrsh7th/cmp-path',
'L3MON4D3/LuaSnip',
'saadparwaiz1/cmp_luasnip',
},
config = function()
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup({
snippet = { expand = function(args) luasnip.lsp_expand(args.body) end },
mapping = cmp.mapping.preset.insert({
['<C-Space>'] = cmp.mapping.complete(),
['<CR>'] = cmp.mapping.confirm({ select = false }),
['<Tab>'] = cmp.mapping(function(fallback)
if cmp.visible() then cmp.select_next_item()
elseif luasnip.expand_or_jumpable() then luasnip.expand_or_jump()
else fallback() end
end, { 'i', 's' }),
}),
sources = cmp.config.sources({
{ name = 'nvim_lsp' },
{ name = 'luasnip' },
{ name = 'buffer' },
{ name = 'path' },
}),
})
end },
}
The select = false on <CR> is opinionated. It means hitting Enter inserts a newline instead of accepting the first completion item unless you explicitly selected one. Senior devs tend to prefer this, autocomplete should never silently change what you typed.
Options Worth Setting
Most Neovim defaults are good. A few are not. The ones I always change:
-- lua/options.lua
local opt = vim.opt
opt.number = true
opt.relativenumber = true
opt.signcolumn = 'yes' -- prevent layout shift when LSP shows diagnostics
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.smartindent = true
opt.wrap = false
opt.ignorecase = true
opt.smartcase = true
opt.undofile = true
opt.updatetime = 250
opt.timeoutlen = 400
opt.splitright = true
opt.splitbelow = true
opt.scrolloff = 8
opt.termguicolors = true
signcolumn = 'yes' is the one most people forget. Without it, the gutter appears and disappears as LSP diagnostics come and go, shifting your code horizontally. Annoying after about 30 seconds.
I covered the navigation side of the setup in Fast code navigation in Neovim, so I won’t repeat the telescope config here. If you’re building from scratch, set up LSP and completion first, telescope second.
Common Pitfalls
A few traps when bootstrapping.
- Forgetting to commit
lazy-lock.json. Without it, every machine drifts to slightly different plugin versions and you’ll spend a Saturday debugging “why doesn’t this work on my laptop.” - Using
ensure_installedin bothmason.nvimandmason-lspconfig. They have different semantics. The mason-lspconfig list uses lspconfig names; the mason list uses package names. Stick to the mason-lspconfig list for servers. - Running
:MasonInstallfor every developer. Useensure_installedand let it run automatically the first time Neovim starts on a new machine. - Setting
mapleaderafter lazy bootstraps. Plugins capture the leader key at load time. Set it in the first three lines ofinit.lua. - Configuring
tsserverfor Deno projects. They conflict. Usedenolsinstead and add aroot_dircheck so they don’t both attach to the same buffer.
Wrapping Up
The whole bootstrap fits in maybe 200 lines of Lua. You can read it in five minutes and understand every line, which is the only way a config stays maintainable. Avoid the temptation to install AstroNvim or LunarVim as a shortcut, you’ll inherit hundreds of decisions you didn’t make.
Once this is running, the next step is the navigation layer (telescope or fzf-lua) and a debugger. The official Neovim user manual is the canonical reference for the editor itself, and the lazy.nvim README covers every spec option in detail.
Boring configs are good configs. Aim for boring.