Ditching the Completion Plugin Stack: What Neovim's Built-in LSP Client Actually Gives You
Neovim ships a real LSP client in core. Here's what it replaces, a minimal working TypeScript setup, and the one place (completion) where a plugin may still earn its keep.
13 Dec 2025, 07:35 UTC

My Neovim config once had seven plugins dedicated to making autocomplete work: a completion engine, three snippet sources, an LSP installer, and two glue layers connecting them. Every Neovim upgrade was a small gamble. The useful takeaway from untangling that mess: since Neovim 0.5, the editor ships a real Language Server Protocol (LSP) client in core, and for a lot of workflows it replaces most of that stack. You trade convenience for a few dozen lines of Lua you actually understand.
What "built-in LSP" means (and what it doesn't)
LSP is a protocol that lets an editor ask a language-specific server questions: where is this symbol defined, what type is this variable, what's wrong on line 42. Neovim's vim.lsp Lua module is the client half of that conversation, compiled into the editor itself. Go-to-definition, hover documentation, rename, diagnostics, and formatting all work through it with zero plugins.
Two things it is not:
- It is not the language server. The server binary —
rust-analyzer,typescript-language-server,lua_ls— must be installed separately, via your package manager or a tool like Mason. Neovim only talks to it. - It is not nvim-lspconfig. The
nvim-lspconfigrepo is a community collection of ready-made server configurations (default root-directory detection, filetypes, command names). It's a convenience layer, not the client. This distinction matters when debugging: if a server won't start, the problem is usually the binary or its config, not "Neovim's LSP being broken."
A minimal working setup
Here's a concrete configuration for TypeScript, targeting Neovim 0.10 with nvim-lspconfig (API details shift between releases — check your version first with :version or :lua print(vim.fn.has('nvim-0.11')), since 0.11 moved toward vim.lsp.config/vim.lsp.enable and different default mappings).
First, install the server. On macOS or Linux with npm available:
npm install -g typescript-language-server typescriptThen, in init.lua (no plugins required beyond lspconfig, which you can also skip by calling vim.lsp.start manually):
-- requires nvim-lspconfig installed via your plugin manager
require('lspconfig').ts_ls.setup{}
-- Keymaps only in buffers where a server actually attaches
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local opts = { buffer = args.buf }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
end,
})
vim.diagnostic.config({ virtual_text = true, underline = true })The LspAttach autocommand is the piece people most often get wrong by skipping it. Mapping gd globally means it silently does nothing (or errors) in buffers without a server. Scoping mappings to attached buffers keeps plain-text editing clean.
To verify: open a .ts file in a project with a tsconfig.json (lspconfig uses that to find the project root), then run :LspInfo (or :checkhealth lsp on newer versions). You should see one client attached to the current buffer. Put the cursor on a function call and press gd; introduce a type error and confirm it appears as virtual text. If nothing attaches, :LspLog shows the client-server handshake and usually names the missing binary or wrong root directory.
The honest trade-off: completion
The one place the native path still feels bare is completion. The built-in client supports it via the omnifunc (<C-x><C-o> in insert mode), and recent versions have improved native auto-completion, but historically there was no auto-popup-as-you-type without a plugin like nvim-cmp or blink.cmp. If you rely on suggestions appearing uninvited, you may keep exactly one completion plugin — which is still a big reduction from seven.
Diagnostics, by contrast, are fully native and independent of your mappings: vim.diagnostic.config controls virtual text, signs, and severity sorting, and vim.diagnostic.jump (or the older goto_next) navigates between problems.
Why bother
The engineering argument isn't minimalism for its own sake. Every plugin you delete is one fewer breaking change on upgrade day, one fewer startup cost, and one fewer abstraction between you and a misbehaving server. When something breaks in a native setup, the debugging surface is :LspLog, :checkhealth lsp, and your own readable Lua — not a chain of three plugins' internals.
Start small: pick one language you edit daily, install its server, add the setup call and the LspAttach mappings above, and work in it for a week. Verify against your installed Neovim version before copying any config from the internet — including this one — because LSP defaults have changed in nearly every recent release. If you miss auto-popup completion after that week, add back a single completion plugin with clear eyes. That's the actual decision the built-in client gives you: features become opt-in choices instead of inherited baggage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.