
Native LSP in Neovim 0.11: zero plugins, zero compromises
What changed with Neovim 0.11
For years, configuring LSP in Neovim meant nvim-lspconfig: default configs for hundreds of servers, lifecycle management. Solid, but one more dependency with its own logic and abstraction layers.
Neovim 0.11 added two functions to the LSP client that change the picture:
vim.lsp.config(): declare a server's config directly inside Neovimvim.lsp.enable(): activate servers for a given filetype
It's a philosophical shift as much as a technical one: LSP becomes Neovim's business, not a plugin's.
The architecture
The whole LSP config fits in one file, lua/plugins/lsp.lua, with three responsibilities: install servers via Mason, configure them natively, and attach the keybindings.
Mason: the server manager
Mason is a package manager specialised for LSP servers, linters and formatters in the Neovim ecosystem.
{
"williamboman/mason.nvim",
cmd = "Mason",
opts = {
ui = {
border = "rounded",
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
},
}
mason-tool-installer guarantees every server is present at startup:
{
"WhoIsSethDaniel/mason-tool-installer.nvim",
dependencies = { "mason.nvim" },
opts = {
ensure_installed = {
-- LSP servers
"lua-language-server",
"typescript-language-server",
"pyright",
"html-lsp",
"css-lsp",
"json-lsp",
"yaml-language-server",
"tailwindcss-language-server",
"emmet-language-server",
-- Formatters
"stylua",
"prettier",
"black",
"isort",
},
},
}
The :Mason command opens a UI to manage installations visually.
Native server configuration
Each server is declared with vim.lsp.config(), then all of them are activated at once with vim.lsp.enable():
-- Capabilities enriched by blink.cmp (autocompletion)
local capabilities = require("blink.cmp").get_lsp_capabilities()
-- lua_ls: Lua with LuaJIT
vim.lsp.config("lua_ls", {
capabilities = capabilities,
settings = {
Lua = {
runtime = { version = "LuaJIT" },
diagnostics = { globals = { "vim" } },
workspace = { checkThirdParty = false },
telemetry = { enable = false },
},
},
})
-- ts_ls: TypeScript / JavaScript
vim.lsp.config("ts_ls", {
capabilities = capabilities,
})
-- pyright: Python with typing
vim.lsp.config("pyright", {
capabilities = capabilities,
})
-- Web servers
vim.lsp.config("html", { capabilities = capabilities })
vim.lsp.config("cssls", { capabilities = capabilities })
vim.lsp.config("jsonls", { capabilities = capabilities })
vim.lsp.config("yamlls", { capabilities = capabilities })
vim.lsp.config("tailwindcss", { capabilities = capabilities })
vim.lsp.config("emmet_language_server", { capabilities = capabilities })
-- Activate every server
vim.lsp.enable({
"lua_ls",
"ts_ls",
"pyright",
"html",
"cssls",
"jsonls",
"yamlls",
"tailwindcss",
"emmet_language_server",
})
Three lua_ls settings deserve explanation:
runtime.version = "LuaJIT"because Neovim uses LuaJIT, not vanilla Lua 5.1diagnostics.globals = { "vim" }otherwise lua_ls reports "variable vim not found" on every lineworkspace.checkThirdParty = falsekills the popup asking to load third-party types on every project open
No servers table to wrap, no for loop, no intermediate abstraction: everything is explicit.
Diagnostics
Configured globally with vim.diagnostic.config(), using icons centralised in config/icons.lua:
local icons = require("config.icons")
vim.diagnostic.config({
signs = {
text = {
[vim.diagnostic.severity.ERROR] = icons.diagnostics.Error,
[vim.diagnostic.severity.WARN] = icons.diagnostics.Warn,
[vim.diagnostic.severity.HINT] = icons.diagnostics.Hint,
[vim.diagnostic.severity.INFO] = icons.diagnostics.Info,
},
},
virtual_text = {
spacing = 4,
prefix = "■",
},
severity_sort = true,
float = {
border = "rounded",
source = true,
},
})
severity_sort = true ensures errors appear before warnings in the sign column, and prefix = "■" gives a discreet marker for inline virtual text.
LSP keybindings
Attached through the LspAttach autocmd, so they only exist in buffers where an LSP server is active:
vim.api.nvim_create_autocmd("LspAttach", {
group = vim.api.nvim_create_augroup("lsp-attach", { clear = true }),
callback = function(event)
local map = function(keys, func, desc, mode)
mode = mode or "n"
vim.keymap.set(mode, keys, func, { buffer = event.buf, desc = "LSP: " .. desc })
end
local telescope = require("telescope.builtin")
-- Navigation
map("gd", telescope.lsp_definitions, "Go to definition")
map("gr", telescope.lsp_references, "Go to references")
map("gI", telescope.lsp_implementations, "Go to implementation")
map("gD", vim.lsp.buf.declaration, "Go to declaration")
-- Information
map("K", vim.lsp.buf.hover, "Hover documentation")
map("<C-k>", vim.lsp.buf.signature_help, "Signature help", "i")
-- Symbols
map("<leader>ds", telescope.lsp_document_symbols, "Document symbols")
map("<leader>ws", telescope.lsp_dynamic_workspace_symbols, "Workspace symbols")
map("<leader>D", telescope.lsp_type_definitions, "Type definition")
-- Actions
map("<leader>rn", vim.lsp.buf.rename, "Rename symbol")
map("<leader>ca", vim.lsp.buf.code_action, "Code action")
end,
})
The day-to-day commands:
gd(go to definition): through Telescope, with a preview of the target file and a picker when several definitions exist.gr(references): every place a symbol is used, essential before a refactor.K(hover): docs for the symbol under the cursor in a floating window — types, signatures, JSDoc.<leader>rn(rename): semantic rename across the project. The LSP understands the code; this isn't find-and-replace.<leader>ca(code action): automatic fixes, missing imports, refactorings offered by the server.
Trouble.nvim for diagnostics
Trouble.nvim adds a dedicated panel aggregating every error and warning in the project into a single view, sorted by severity:
{
"folke/trouble.nvim",
cmd = "Trouble",
keys = {
{ "<leader>xx", "<cmd>Trouble diagnostics toggle<cr>", desc = "Diagnostics (Trouble)" },
{ "<leader>xX", "<cmd>Trouble diagnostics toggle filter.buf=0<cr>", desc = "Buffer diagnostics" },
},
opts = {
use_diagnostic_signs = true,
},
}
Particularly useful on a project with many files, where hunting errors file by file gets tedious fast.
Why drop lspconfig
- Fewer dependencies: one less plugin to update when something breaks.
- Forward-compatible: you use Neovim's official API, not a third-party abstraction.
- Explicit: each server is declared clearly, no magic.
- Simple: no need to know how lspconfig resolves server names or merges configs.
For 9 servers the line-count difference is minimal — about fifteen. The clarity is what pays.
Conclusion
Neovim 0.11's native LSP gives a complete environment — go-to-definition, autocompletion, diagnostics, rename, code actions — with just Mason to install servers and a few calls to the native API. Simpler, more transparent, and every piece of the setup stays understandable because none of it hides behind an abstraction layer.
Related articles