background-shape
Bootstrapping Neovim with lazy.nvim and Mason, A Backend Dev Setup
April 3, 2024 · 6 min read · by Muhammad Amal programming

TL;DR — A working backend Neovim setup needs lazy.nvim as the plugin manager, mason.nvim for tool installation, and nvim-lspconfig to 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_installed in both mason.nvim and mason-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 :MasonInstall for every developer. Use ensure_installed and let it run automatically the first time Neovim starts on a new machine.
  • Setting mapleader after lazy bootstraps. Plugins capture the leader key at load time. Set it in the first three lines of init.lua.
  • Configuring tsserver for Deno projects. They conflict. Use denols instead and add a root_dir check 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.