Configure Neovim's Built‑in LSP Client in init.lua Without Plugins
Learn to wire Neovim's native LSP client directly in init.lua: start a server, attach on LspAttach, and add buffer‑local keymaps—no plugins required.
01 Aug 2025, 12:18 UTC

Quick answer
Neovim ships a full LSP client in the vim.lsp namespace. You can start any language server by calling vim.lsp.start_client() with a command table, then attach buffer‑local mappings and settings inside an LspAttach autocmd. No external plugin manager is required.
Minimal worked example: TypeScript / JavaScript
The following init.lua snippet starts typescript-language-server (installed globally via npm i -g typescript-language-server) and provides the most common navigation keys.
-- init.lua
local lsp = vim.lsp
local api = vim.api
-- 1. Define the server command. Adjust the path if the binary lives elsewhere.
local ts_server = {
name = 'tsserver',
cmd = { 'typescript-language-server', '--stdio' },
root_dir = function(fname)
return lsp.util.root_pattern('package.json', 'tsconfig.json', '.git')(fname) or vim.loop.cwd()
end,
init_options = { preferences = { includeCompletionsWithSnippetText = true } },
}
-- 2. Start the client when a matching buffer is opened.
api.nvim_create_autocmd('FileType', {
pattern = { 'typescript', 'typescriptreact', 'javascript', 'javascriptreact' },
callback = function()
lsp.start_client(ts_server)
end,
})
-- 3. Buffer‑local keymaps and options once a client attaches.
api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufnr = args.buf
local client = lsp.get_client_by_id(args.data.client_id)
if not client then return end
-- Enable omnifunc for built‑in completion
vim.bo[bufnr].omnifunc = 'v:lua.vim.lsp.omnifunc'
local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'gd', lsp.buf.definition, opts)
vim.keymap.set('n', 'gr', lsp.buf.references, opts)
vim.keymap.set('n', 'K', lsp.buf.hover, opts)
vim.keymap.set('n', 'rn', lsp.buf.rename, opts)
vim.keymap.set('n', 'ca', lsp.buf.code_action, opts)
vim.keymap.set('n', '[d', function() vim.diagnostic.goto_prev({ buffer = bufnr }) end, opts)
vim.keymap.set('n', ']d', function() vim.diagnostic.goto_next({ buffer = bufnr }) end, opts)
end,
})
How it works
- Server command table –
cmdis the exact argv passed toexecvp. The server must speak the Language Server Protocol over stdin/stdout. - Root directory detection –
root_dirreceives the current file name and returns a project root. The example useslsp.util.root_patternto look forpackage.json,tsconfig.jsonor.git. - FileType autocmd – Starts the client only for relevant buffers, avoiding unnecessary processes.
- LspAttach autocmd – Runs after a client successfully attaches to a buffer. The callback receives
args.data.client_idso you can fetch the client object and set buffer‑local options (omnifunc, keymaps, etc.).
Verification steps
Run these commands in Neovim after opening a TypeScript file:
:echo vim.lsp.get_clients()– should list a client withname = "tsserver"andattached_bufferscontaining the current buffer number.:lua vim.lsp.buf.definition()– cursor jumps to the definition or prints"No definition provider"if the symbol has none.:LspLog– opens the LSP log; look forStarting client "tsserver"and anystderrlines that indicate a missing binary or permission error.
Limits of the built‑in client
- No bundled servers – You must install each language server yourself (via npm, cargo, pip, Mason, etc.).
- No automatic configuration – Settings such as
init_options,settings, orcapabilitiesare entirely manual. - API stability –
vim.lsp.start_clientsignature changed between Neovim 0.8 and 0.10. Always check:help vim.lspfor the version you run. - Single‑threaded Lua – Long‑running callbacks (e.g., a custom
on_initthat does I/O) block the UI. Keep callbacks short or defer work withvim.schedule.
Common mistakes and how to avoid them
| Mistake | Symptom | Fix |
|---|---|---|
Hard‑coding cmd = { "tsserver" } when the binary is named typescript-language-server |
Silent attach failure; :LspLog shows spawn: ENOENT |
Use the exact executable name or full path; verify with which typescript-language-server in the shell. |
Placing LspAttach keymaps outside the autocmd |
Keymaps exist on every buffer, even those without a client | Define mappings only inside the LspAttach callback (as shown). |
Assuming root_dir returns a non‑nil value |
Client starts with cwd as root, causing wrong workspace symbols |
Provide a fallback: or vim.loop.cwd(). |
Using vim.lsp.buf.definition() in a mapping without silent = true |
Command echo clutters the command line | Add silent = true to the keymap options table. |
Extending the pattern
Repeat the FileType + LspAttach pair for each language. For example, a Rust setup would use cmd = { "rust-analyzer" } and root_pattern("Cargo.toml"). Because the logic lives in plain Lua, you can factor the common parts into a small helper function if you manage many servers.
TL;DR checklist
- Install the language server binary in
$PATH. - Add a
FileTypeautocmd that callsvim.lsp.start_clientwith a command table. - Inside
LspAttach, setomnifuncand buffer‑local keymaps. - Verify with
:echo vim.lsp.get_clients(),:lua vim.lsp.buf.definition(), and:LspLog.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.