Fransys

Tech blog — Architecture, Cloud & DevOps

BlogServicesContactAbout

Follow me

githubGitHublinkedinLinkedinmailMail

© 2026 Fransys • Fransys

Fransys

Categories

  • All posts
  • Tags
  • productivity10
  • nas10
  • ai8
  • security7
  • self-hosting7
  • linux6
  • claude-code6
  • neovim5
  • docker5
  • editor4
  • networking4
  • mcp3
  • vpn3
  • lua2
  • terminal2
neovimlspeditorproductivity

Native LSP in Neovim 0.11: zero plugins, zero compromises

Published on
January 16, 2026·6 min read
Avatar François GUERLEZFrançois GUERLEZ

What changed with Neovim 0.11

For years - and I mean years - LSP configuration in Neovim meant using nvim-lspconfig. That plugin did the job: defaults for hundreds of servers, lifecycle management, the whole deal. It was solid. But it was also another dependency, with its own abstractions and quirks. One more thing to maintain.

Neovim 0.11 actually changed things.

The built-in LSP client got two new functions. Just two. But they matter:

  • vim.lsp.config(): you declare an LSP server config directly in Neovim
  • vim.lsp.enable(): you activate servers for specific filetypes

It's a philosophical shift as much as a technical one. Neovim now says "LSP is ours, it's native". That's a big deal.

My LSP configuration architecture

All my LSP setup lives in one file: lua/plugins/lsp.lua. No sprawl across three folders. No 15 import statements. Just one. And it handles three things: install servers with Mason, configure them natively, attach keybindings.

Mason: the server manager

Mason is your package manager for LSP servers, linters, formatters. It's not pip or npm. It's specialized for dev tooling in Neovim.

{
  "williamboman/mason.nvim",
  cmd = "Mason",
  opts = {
    ui = {
      border = "rounded",
      icons = {
        package_installed = "✓",
        package_pending = "➜",
        package_uninstalled = "✗",
      },
    },
  },
}

For auto-installation, I use mason-tool-installer which ensures all servers are 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 graphical interface with rounded borders and icons for visually managing installations.

Native configuration for 9 servers

Here's the meat of it. Direct and explicit. Declare each server with vim.lsp.config(), then activate them all 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 type checking
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 })

-- Enable all servers
vim.lsp.enable({
  "lua_ls",
  "ts_ls",
  "pyright",
  "html",
  "cssls",
  "jsonls",
  "yamlls",
  "tailwindcss",
  "emmet_language_server",
})

Some details about the lua_ls config worth explaining:

  • runtime.version = "LuaJIT": because we code for Neovim, which uses LuaJIT, not vanilla Lua 5.1
  • diagnostics.globals = { "vim" }: otherwise lua_ls screams at every line about "vim is undefined". Annoying.
  • workspace.checkThirdParty = false: kills that popup that asks if you want to load third-party types every time you open the project

This is cleaner than lspconfig. No servers wrapper table, no for loops, no hidden abstraction. Everything is explicit. You see what's happening.

Diagnostics

Diagnostics (errors, warnings, hints) are configured globally with vim.diagnostic.config(). I use custom icons defined in a centralized config/icons.lua file:

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,
  },
})

The severity_sort = true is important: errors always appear before warnings in the signcolumn. The prefix = "■" provides a discreet but visible marker for inline diagnostic virtual text.

LSP keybindings

Keybindings are attached via the LspAttach autocmd, which ensures they are only available 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 commands you actually live in every day:

  • gd (go to definition): the one you hammer a hundred times. Via Telescope, you see a preview of the target file. Multiple definitions? You pick. Magic.
  • gr (references): shows you everywhere a symbol gets called. Essential before refactoring anything.
  • K (hover): pops documentation for the symbol under your cursor in a floating window. Types, signatures, JSDoc. Everything. Free, courtesy of the LSP server.
  • <leader>rn (rename): renames a symbol everywhere in the project semantically. The LSP understands the code, not just doing dumb find-and-replace.
  • <leader>ca (code action): automatic fixes, missing imports, refactorings the server suggests. Just press the button.

Trouble.nvim for diagnostics

Complementing the native LSP, Trouble.nvim provides a dedicated diagnostics panel. The <leader>xx shortcut opens a structured list of all errors and warnings in the project:

{
  "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,
  },
}

This is particularly useful on projects with many files: instead of navigating file by file to find errors, Trouble aggregates them in a single view, sorted by severity.

Why not use lspconfig?

Fair question. If lspconfig works, why remove it?

  • Fewer dependencies: one less plugin. One less thing that can break on update.
  • Forward-compatible: you use Neovim's official API, not a third-party wrapper. Neovim evolves? Your code evolves naturally.
  • Explicit: each server is declared clearly, no magic. You know exactly what's happening.
  • Simple: you don't need to understand how lspconfig resolves names or merges configs. Because you're not using it.

Yeah, for 9 servers, the difference is maybe 15-20 lines of code. Not huge. But the clarity you gain? Worth it.

The conclusion

Native LSP in Neovim 0.11 is serious business. You get a complete dev environment - go-to-definition, autocompletion, diagnostics, rename, code actions - with just Mason for server installs and a few direct API calls. Simpler. More transparent. And you understand every piece of your setup because it's not hidden behind some abstraction layer.


Neovim from scratch series — This article is part of a complete series on configuring Neovim from scratch.

Previous: Configuring Neovim from scratch with Lua and Lazy.nvim | Next: Telescope, Treesitter and essential coding plugins for Neovim

Previous post

← Securing SSH with post-quantum algorithms

Next post

Firewall and Fail2ban: locking down NAS network access→
← Back to blog

Table of Contents

  • What changed with Neovim 0.11
  • My LSP configuration architecture
  • Mason: the server manager
  • Native configuration for 9 servers
  • Diagnostics
  • LSP keybindings
  • Trouble.nvim for diagnostics
  • Why not use lspconfig?
  • The conclusion