Native LSP in Neovim 0.11: zero plugins, zero compromises

Native LSP in Neovim 0.11: zero plugins, zero compromises

·6 min read·Updated on January 16, 2026

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 Neovim
  • vim.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.1
  • diagnostics.globals = { "vim" } otherwise lua_ls reports "variable vim not found" on every line
  • workspace.checkThirdParty = false kills 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.

ShareLinkedInXBluesky

Related articles