Skip to main content

Command Palette

Search for a command to run...

Neovim LSP Setup: nvim-lspconfig + lazy.nvim Explained

A step-by-step guide to a clean, modern Language Server setup in Neovim

Updated
7 min read
Neovim LSP Setup: nvim-lspconfig + lazy.nvim Explained

Setting up Language Server Protocol (LSP) support in Neovim used to mean juggling long configuration files, custom autocommands, and a fair amount of trial and error. While nvim-lspconfig has always provided the building blocks, managing when and how those pieces load could quickly become messy—especially as your configuration grows.

With the rise of lazy.nvim, Neovim plugin management has shifted toward a more declarative, performance-focused approach. Plugins load only when they’re needed, configurations become easier to reason about, and startup time stays fast even as your setup becomes more powerful. When combined with nvim-lspconfig, this approach results in an LSP configuration that is both clean and flexible.

In this post, we’ll walk through a modern Neovim LSP setup using nvim-lspconfig and lazy.nvim. You’ll see how to structure your configuration, load LSP support at the right moment, and avoid common pitfalls—ending up with a maintainable setup that scales as your editor and workflow evolve.

As we saw in the previous post, we are working with the following folder structure:

This folder structure reflects a clean and modular approach to configuring Neovim, using lazy.nvim as the plugin manager and nvim-lspconfig for LSP support. The init.lua file is Neovim’s main entry point and typically only contains minimal bootstrap logic, delegating most of the setup to Lua modules. The lazy-lock.json file is automatically generated by lazy.nvim and ensures reproducible plugin versions by locking dependencies to specific commits. Inside the lua/ directory, configuration is split by responsibility: config/lazy.lua is dedicated to initializing and configuring lazy.nvim itself (including plugin loading options), while the plugins/ directory contains individual plugin specifications. In this case, plugins/lsp.lua encapsulates all LSP-related configuration, such as server setup.

Let’s start with the configuration for the LSP client in the core config nvim/lua/config/lazy.lua file. Depending on the project size and its dependencies, the LSP server can take a noticeable amount of time. In these situations, having visual feedback during initialization is especially helpful, as it lets you know that the LSP is working and gives you an idea of how long it will take to become ready. Neovim’s built-in LSP client supports this by exposing a function that can be integrated into your statusline, allowing you to see the current LSP progress and initialization status at a glance.

----------------------------------------------------------------------
-- LSP progress
----------------------------------------------------------------------
vim.api.nvim_create_autocmd("LspProgress", {
  group = vim.api.nvim_create_augroup("LspProgressStatusline", { clear = true }),
  callback = function()
    vim.cmd("redrawstatus")
  end,
})

vim.opt.statusline:append("%{v:lua.vim.lsp.status()}")

We can now test our configuration by closing Neovim and opening it again with the file lazy.lua and check how the LSP initialization status gets reported on the status line. Once the loading process finishes, the LSP is ready to show diagnostics, if any.

One of the key capabilities of an LSP client is its support for real-time diagnostics, such as warnings, errors, and hints displayed directly in the editor. This effectively acts as a first line of defense when working with compiled languages, surfacing issues as you type rather than waiting for a full build to fail. By highlighting problems inline and providing precise messages, the LSP offers immediate feedback on mistakes, questionable patterns, or potential bugs, allowing you to correct them early and maintain a faster, more confident development flow.

----------------------------------------------------------------------
-- Diagnostics (INLINE)
----------------------------------------------------------------------
vim.diagnostic.config({
  virtual_text = {
    spacing = 4,
    prefix = "●", -- could be "■", "▎", ""
  },
  signs = true,
  underline = true,
  update_in_insert = false,
  severity_sort = true,
})

This configuration customizes how Neovim displays LSP diagnostics, focusing on clear and non-intrusive inline feedback. The virtual_text option enables inline diagnostic messages and fine-tunes their appearance: spacing = 4 adds padding between the code and the message for better readability, while the prefix = "●" inserts a small visual marker to quickly draw attention to problematic lines without overwhelming the code. Enabling signs places icons in the sign column, providing an at-a-glance indication of errors or warnings, and underline = true visually emphasizes the exact range of code associated with the issue. Setting update_in_insert = false prevents diagnostics from updating while you are typing, reducing visual noise and distractions during insertion. Finally, severity_sort = true ensures that when multiple diagnostics exist on the same line, more severe issues (such as errors) take priority over warnings or hints, helping you focus on what matters most first.

Now that the basic LSP setup is in place, we can move on to configuring a language server for a specific language and see the LSP client in action. We’ll do this in the nvim/lua/plugins/lsp.lua file, which contains the specification for the nvim-lspconfig plugin. Each plugin spec is made up of several configurable parts; here, we’ll focus on defining when the plugin should be loaded and setting its core configuration options. We are going to configure the Lua language server. That way, it can help you out from now on to spot any issues on the configuration files for other plugins. And you can explore more easily the APIs provided by Neovim to implement extra functionality.

return {
  "neovim/nvim-lspconfig",
  event = { "BufReadPre", "BufNewFile" },
  config = function()

    ----------------------------------------------------------------------
    -- Lua LSP configuration
    ----------------------------------------------------------------------
    vim.lsp.config("lua_ls", {
      settings = {
        Lua = {
          runtime = { version = "LuaJIT" },
          diagnostics = {
            globals = { "vim" },
          },
          workspace = {
            checkThirdParty = false,
            library = vim.api.nvim_get_runtime_file("", true),
          },
          telemetry = { enable = false },
        },
      },
    })

    -- Enable Lua LSP
    vim.lsp.enable("lua_ls")

This plugin specification defines how nvim-lspconfig is loaded and how the Lua language server is configured in a lazy.nvim setup. The plugin is lazy-loaded on the BufReadPre and BufNewFile events, meaning it is only initialized when you open an existing file or create a new one, which helps keep Neovim’s startup time fast. Inside the config function, the Lua language server (lua_ls) is configured using vim.lsp.config, with settings tailored specifically for Neovim development. The runtime is set to LuaJIT, matching the Lua version embedded in Neovim, and the diagnostics are instructed to recognize vim as a global variable to avoid false warnings. The workspace configuration disables third-party checks and adds Neovim’s runtime files to the server’s library, enabling better completion and navigation for Neovim APIs. Telemetry is explicitly disabled to avoid sending usage data. Finally, vim.lsp.enable("lua_ls") activates the server, ensuring the configuration is applied and the Lua LSP starts automatically when editing Lua files. You can test its functionality with the default keybinding ‘K’ that shows the hover information of a symbol.

To ease navigation over autocompletion options, we will define a couple of keybindings. These keybindings enhance the LSP auto-completion workflow by making <Tab> and <S-Tab> context-aware in insert mode. When the completion pop-up menu is visible (pumvisible()), <Tab> and <S-Tab> are repurposed to navigate forward and backward through the list of completion candidates using <C-n> and <C-p>, allowing you to quickly browse suggestions without leaving the home row. When no completion menu is shown, pressing <Tab> explicitly triggers omni-completion (<C-x><C-o>), which asks the active LSP server for context-aware suggestions such as symbols, methods, or types. This dual behavior keeps completion both discoverable and fluid: you can trigger LSP completion on demand and seamlessly navigate results with familiar keys, reducing friction and making auto-completion feel like a natural extension of typing rather than a separate action.

-- Omni complete
vim.keymap.set("i", "<Tab>", function()
  if vim.fn.pumvisible() == 1 then
    return "<C-n>"
  end
  return "<C-x><C-o>"
end, { expr = true })

vim.keymap.set("i", "<S-Tab>", function()
  if vim.fn.pumvisible() == 1 then
    return "<C-p>"
  end
  return "<S-Tab>"
end, { expr = true })

Wrapping things up, configuring nvim-lspconfig with lazy.nvim gives you a clean, modular, and performant way to manage LSP support in Neovim. By letting lazy.nvim handle loading and dependencies, you keep your configuration declarative and easy to reason about, while nvim-lspconfig focuses on what it does best: connecting language servers to your editor. This setup scales naturally as you add more servers, tools, or customizations, and it encourages a workflow where your editor grows with your needs instead of fighting them. With this foundation in place, you’re well-equipped to fine-tune diagnostics, keymaps, and capabilities—and make Neovim feel truly tailored to your development style.