Configure Neovim – Setting Up an LSP

Published on Mar 10, 2026

Configure Neovim – Setting Up an LSP

Up to this point in our Neovim configuration series, we have been building out the look and feel of the editor. We have a colorscheme, a statusline, a file explorer, and some quality-of-life options in place. What we are still missing is the thing that makes a code editor feel like an actual development environment — code intelligence.

That is what the Language Server Protocol, or LSP, gives us. With an LSP set up, Neovim can tell you when something is wrong with your code before you run it, show you the documentation for a function without leaving the file, jump straight to where a variable is defined, and rename a symbol across every file in the project at once. These are the features you are used to from VS Code or any other modern IDE, and we can have all of them inside Neovim.

In this post, we will be using three plugins to get this working: mason.nvim , mason-lspconfig.nvim, and nvim-lspconfig. Each one plays a distinct role, so let me explain what they all do before we write any code.


How the Three Plugins Fit Together

nvim-lspconfig is the foundation. It is an official Neovim plugin that provides ready-made configurations for over 200 language servers. Without it, you would have to write the configuration for each server entirely by hand, which is tedious and error-prone. With it, connecting to a language server is usually one or two lines.

mason.nvim is a package manager for language servers, linters, formatters, and debuggers. Think of it as a self-contained install manager that lives inside Neovim. Instead of hunting down the installation instructions for each language server separately, you open Mason’s interface and install whatever you need from there. Mason downloads, installs, and updates everything to a directory inside Neovim’s own data folder, so it does not touch your system package manager.

mason-lspconfig.nvim is the bridge between the two. Without it, you would have to manually call require("lspconfig").server_name.setup({}) for every server you install through Mason. With it, Mason and lspconfig are aware of each other, and you can declare a list of servers you want installed and configured in one place.

The three plugins need to be loaded in a specific order: mason.nvim first, then nvim-lspconfig, then mason-lspconfig.nvim. We will handle this through Lazy.nvim’s dependencies field so it is taken care of automatically.


Prerequisites

Before continuing, make sure you have the following already in place from earlier posts in this series:

  • Lazy.nvim set up as your plugin manager
  • The folder structure lua/config/ and lua/plugins/ inside your Neovim config directory
  • A terminal with a working internet connection — Mason will be downloading language servers

You will also want to make sure that the runtimes for the languages you plan to work with are already installed on your system. For example, if you want TypeScript support, Node.js must already be on your machine. Mason installs the language server for you, but it does not install the language it’self.


Installing the Plugins

Inside your lua/plugins/ folder, create a new file called lsp.lua. We will put all three plugin specs in this single file so everything related to LSP configuration stays in one place.

-- lua/plugins/lsp.lua

return {
  {
    "mason-org/mason-lspconfig.nvim",
    dependencies = {
      { "mason-org/mason.nvim", opts = {} },
      "neovim/nvim-lspconfig",
    },
    opts = {},
  },
}

Save the file and run :Lazy sync in Neovim. All three plugins will be installed. Once the installation is complete, run :Mason to open the Mason interface and verify it is working. You should see a panel listing available packages. We will come back to this shortly.


Installing Your First Language Server

Let us install a language server to confirm that everything is wired up correctly. We will use lua_ls, the Lua language server, since we are already writing Lua for our Neovim config and it is a good one to have.

Open the Mason interface with :Mason. Once it is open, press 2 to jump to the LSP section of the package list. Use j and k to scroll through the list until you find lua-language-server, then press i to install it. Mason will download and install it in the background. A progress indicator will appear at the top of the panel. Once it is done, close the panel with q.

You can verify the installation worked by opening any .lua file and running :LspInfo. This command shows you which language servers are currently attached to the buffer. If lua_ls appears in the list, the server is running and connected.


Declaring Servers in Config

Opening Mason and pressing i every time you add a new language server gets tedious, especially if you switch machines or share your config with others. A better approach is to declare all the servers you want inside your Lazy.nvim spec using the ensure_installed option. Mason will then install them automatically on startup if they are not already present.

Let us expand the plugin spec to include this:

-- lua/plugins/lsp.lua

return {
  {
    "mason-org/mason-lspconfig.nvim",
    dependencies = {
      {
        "mason-org/mason.nvim",
        opts = {
          ui = {
            border = "rounded",
            icons = {
              package_installed   = "✓",
              package_pending     = "➜",
              package_uninstalled = "✗",
            },
          },
        },
      },
      "neovim/nvim-lspconfig",
    },
    opts = {
      -- Servers listed here will be installed automatically
      ensure_installed = {
        "lua_ls",       -- Lua
        "ts_ls",        -- TypeScript / JavaScript
        "html",         -- HTML
        "cssls",        -- CSS
        "pyright",      -- Python
        "jsonls",       -- JSON
      },
    },
  },
}

Replace or trim the ensure_installed list to match the languages you actually work with. The full list of available server names can be found on the mason-lspconfig GitHub page under the Available LSP servers section.

The mason.nvim opts block above also adds some cosmetic improvements. The rounded border makes the Mason panel look a little more polished, and the custom icons make it easy to see at a glance which servers are installed and which are pending.


Adding LSP Keymaps

Having a language server attached to a buffer does not help much unless we have keymaps to trigger it’s features. The standard approach in Neovim is to attach keymaps only when an LSP actually connects to a buffer, using the LspAttach autocommand. This way the keymaps are only active in buffers where a server is running.

We can add this configuration inside the nvim-lspconfig dependency block, or in a separate lua/config/lsp.lua file and require it from there. I will keep it in the same lsp.lua plugin file to keep things tidy.

-- lua/plugins/lsp.lua

-- Set up keymaps when an LSP attaches to a buffer
vim.api.nvim_create_autocmd("LspAttach", {
  group = vim.api.nvim_create_augroup("UserLspConfig", {}),
  callback = function(event)
    local map = function(keys, func, desc)
      vim.keymap.set("n", keys, func, {
        buffer = event.buf,
        desc = "LSP: " .. desc,
      })
    end

    -- Jump to where a symbol is defined
    map("gd", vim.lsp.buf.definition, "Go to definition")

    -- Jump to the declaration (useful in C/C++)
    map("gD", vim.lsp.buf.declaration, "Go to declaration")

    -- List all implementations of an interface or abstract method
    map("gi", vim.lsp.buf.implementation, "Go to implementation")

    -- Show type definition
    map("gt", vim.lsp.buf.type_definition, "Go to type definition")

    -- List all references to the symbol under the cursor
    map("gr", vim.lsp.buf.references, "Show references")

    -- Show documentation for the symbol under the cursor
    map("K", vim.lsp.buf.hover, "Hover documentation")

    -- Show signature help (useful when inside a function call)
    map("<C-k>", vim.lsp.buf.signature_help, "Signature help")

    -- Rename the symbol under the cursor across all files
    map("<leader>rn", vim.lsp.buf.rename, "Rename symbol")

    -- Run a code action (fix suggestions from the LSP)
    map("<leader>ca", vim.lsp.buf.code_action, "Code action")

    -- Format the current buffer using the LSP
    map("<leader>fm", function()
      vim.lsp.buf.format({ async = true })
    end, "Format buffer")
  end,
})

return {
  {
    "mason-org/mason-lspconfig.nvim",
    dependencies = {
      {
        "mason-org/mason.nvim",
        opts = {
          ui = {
            border = "rounded",
            icons = {
              package_installed   = "✓",
              package_pending     = "➜",
              package_uninstalled = "✗",
            },
          },
        },
      },
      "neovim/nvim-lspconfig",
    },
    opts = {
      ensure_installed = {
        "lua_ls",
        "ts_ls",
        "html",
        "cssls",
        "pyright",
        "jsonls",
      },
    },
  },
}

The LspAttach autocmd fires every time a language server connects to a buffer, so the keymaps will automatically be set up on any file where a server is active.

Here is a quick summary of the most useful mappings:

KeymapAction
gdJump to the definition
gDJump to the declaration
grShow all references
giShow implementations
KShow hover documentation
<leader>rnRename symbol
<leader>caRun code action
<leader>fmFormat the buffer

Configuring a Specific Server

Most servers work fine with their default settings, but some need a little extra configuration. The Lua language server is a good example. By default, it does not know about the vim global that Neovim exposes, so it will underline it with a warning everywhere in your config files. We can fix this by passing custom settings to lua_ls through nvim-lspconfig.

To pass server-specific settings, we use vim.lsp.config (available in Neovim 0.11 and later) before the server is enabled:

-- Add this block above the return statement in lua/plugins/lsp.lua

vim.lsp.config("lua_ls", {
  settings = {
    Lua = {
      diagnostics = {
        -- Tell lua_ls that "vim" is a known global
        globals = { "vim" },
      },
      workspace = {
        -- Make the server aware of Neovim runtime files
        library = vim.api.nvim_get_runtime_file("", true),
        checkThirdParty = false,
      },
      telemetry = {
        enable = false,
      },
    },
  },
})

With this in place, the vim global warning disappears from your Neovim config files and the Lua language server becomes much more useful for writing Lua inside Neovim.


Working with Diagnostics

When a language server detects a problem in your code, it reports it as a diagnostic. By default, Neovim shows these as underlines in the buffer and small signs in the gutter. We can improve the appearance and behaviour of diagnostics with a call to vim.diagnostic.config.

Add this above the return statement in your lsp.lua:

vim.diagnostic.config({
  virtual_text = {
    prefix = "●",  -- show a dot before the inline message
  },
  signs = true,
  underline = true,
  update_in_insert = false,  -- do not update diagnostics while typing
  severity_sort = true,      -- show errors before warnings
  float = {
    border = "rounded",
    source = true,  -- show which LSP reported the diagnostic
  },
})

-- Replace the default diagnostic signs in the gutter with cleaner icons
local signs = {
  Error = " ",
  Warn  = " ",
  Hint  = "󰠠 ",
  Info  = " ",
}
for type, icon in pairs(signs) do
  local hl = "DiagnosticSign" .. type
  vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = "" })
end

Setting update_in_insert = false is worth calling out specifically. Without it, the diagnostic virtual text updates on every keystroke while you are typing, which can be distracting and sometimes jumpy. Turning this off means the diagnostics refresh only when you leave insert mode, which feels much more natural.

You can view the full diagnostic list for the current buffer with :lua vim.diagnostic.open_float(), or navigate between diagnostics using ]d and [d (the default Neovim keymaps for next and previous diagnostic).


Putting It All Together

Here is the full lua/plugins/lsp.lua file with everything from this post included:

-- lua/plugins/lsp.lua

-- Diagnostic appearance
vim.diagnostic.config({
  virtual_text = {
    prefix = "●",
  },
  signs = true,
  underline = true,
  update_in_insert = false,
  severity_sort = true,
  float = {
    border = "rounded",
    source = true,
  },
})

local signs = {
  Error = " ",
  Warn  = " ",
  Hint  = "󰠠 ",
  Info  = " ",
}
for type, icon in pairs(signs) do
  local hl = "DiagnosticSign" .. type
  vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = "" })
end

-- Custom lua_ls settings for Neovim config files
vim.lsp.config("lua_ls", {
  settings = {
    Lua = {
      diagnostics = {
        globals = { "vim" },
      },
      workspace = {
        library = vim.api.nvim_get_runtime_file("", true),
        checkThirdParty = false,
      },
      telemetry = {
        enable = false,
      },
    },
  },
})

-- Attach keymaps when an LSP connects to a buffer
vim.api.nvim_create_autocmd("LspAttach", {
  group = vim.api.nvim_create_augroup("UserLspConfig", {}),
  callback = function(event)
    local map = function(keys, func, desc)
      vim.keymap.set("n", keys, func, {
        buffer = event.buf,
        desc = "LSP: " .. desc,
      })
    end

    map("gd",         vim.lsp.buf.definition,    "Go to definition")
    map("gD",         vim.lsp.buf.declaration,   "Go to declaration")
    map("gi",         vim.lsp.buf.implementation,"Go to implementation")
    map("gt",         vim.lsp.buf.type_definition,"Go to type definition")
    map("gr",         vim.lsp.buf.references,    "Show references")
    map("K",          vim.lsp.buf.hover,          "Hover documentation")
    map("<C-k>",      vim.lsp.buf.signature_help, "Signature help")
    map("<leader>rn", vim.lsp.buf.rename,         "Rename symbol")
    map("<leader>ca", vim.lsp.buf.code_action,    "Code action")
    map("<leader>fm", function()
      vim.lsp.buf.format({ async = true })
    end, "Format buffer")
  end,
})

return {
  {
    "mason-org/mason-lspconfig.nvim",
    dependencies = {
      {
        "mason-org/mason.nvim",
        opts = {
          ui = {
            border = "rounded",
            icons = {
              package_installed   = "✓",
              package_pending     = "➜",
              package_uninstalled = "✗",
            },
          },
        },
      },
      "neovim/nvim-lspconfig",
    },
    opts = {
      ensure_installed = {
        "lua_ls",
        "ts_ls",
        "html",
        "cssls",
        "pyright",
        "jsonls",
      },
    },
  },
}

Verifying the Setup

Restart Neovim after saving the file. Lazy.nvim will install the plugins on the first launch, and Mason will start downloading the servers listed in ensure_installed. This can take a minute or two depending on your connection.

Once everything is installed, open a file for one of the languages you configured — a .lua file is the easiest to test since we set up lua_ls. Move the cursor over any function or variable and press K. A floating window should appear with the documentation for that symbol. That confirms the LSP is attached and working.

Try introducing a deliberate error — for example, call a function with the wrong number of arguments — and move the cursor away. Within a second you should see an underline and a diagnostic message appear. That is the language server doing it’s job.


Wrapping Up

With LSP support in place, our Neovim setup has crossed an important line. It is no longer just a fast text editor — it is now a proper development environment with the same level of code intelligence you would get from a full IDE.

From here, the natural next step is to add autocompletion, which takes the information the LSP already provides and surfaces it as you type through a completion menu. We will cover that in the next post using nvim-cmp and LuaSnip.

To find the rest of my posts on Neovim , click here.

Author Information

aeon501
aeon501

Web Developer, Restless. My mind goes on epic voyages, then return back to reality. I write about things I have experienced in my coding journey.

View all posts
Advertisement
Ad placeholder
Sponsored
Ad 2