Skip to content
Go back

Neovim Setup Without the 6-Month Slog

By SumGuy 13 min read
Neovim Setup Without the 6-Month Slog
Contents

The Vim Hate is Actually About Bad Config

You’ve heard it a hundred times: “Vim has a steep learning curve.” What they actually mean is “Vim with a garbage config has a steep learning curve.” Throw in six months of plugin hunting, a bloated init.vim with conflicting keybindings, and LSP wired up against an API that got deprecated last year, and yeah, you’ll hate it.

Neovim in 2026 is not your dad’s Vim. Neovim 0.12.5 shipped on 23 August 2026 with a built-in LSP client, Lua as the config language, and enough sane defaults that most of the set lines you’ll copy off the internet do nothing. You can have a working editor in a week.

This config is about 120 lines you can read start to finish. Every line either changes a default or wires up something Neovim doesn’t do on its own. Nothing is there because a blog post told me to add it.


The Philosophy: Start Minimal, Add When It Hurts

Don’t start with a framework. LunarVim, the usual recommendation here, has had no commits since 5 June 2025, so anyone installing it today is installing a 15-month-old opinion about a plugin ecosystem that moved twice since. Don’t grab someone’s 2000-line config and cargo-cult it into ~/.config/nvim/ either. You’ll spend the six months you were trying to avoid debugging someone else’s keymaps.

Start with 100 lines. Use the editor for a week. When something hurts, add a plugin. Repeat.

You get:


The Directory Structure

~/.config/nvim/
init.lua entry point, ~10 lines
lua/config/
settings.lua options
keymaps.lua keybindings
lazy.lua lazy.nvim bootstrap
lua/plugins/
lsp.lua LSP client setup
telescope.lua fuzzy finder
colorscheme.lua colors

Each file has one job. Note there is no lua/plugins/init.lua. That file is a common mistake and I’ll come back to why.


Part 1: The Entry Point (init.lua)

~/.config/nvim/init.lua
-- Leader must be set before lazy.nvim loads, or plugin keymaps
-- get bound to the old leader.
vim.g.mapleader = " "
vim.g.maplocalleader = " "
require("config.settings")
require("config.keymaps")
require("config.lazy")

Three requires and two globals. A lot of configs floating around add a fourth line here:

require("lazy").setup(require("plugins")) -- don't do this

If lua/config/lazy.lua already called require("lazy").setup(), that second call does nothing. lazy.nvim sets vim.g.lazy_did_setup on the first call and every later one bails out with Re-sourcing your config is not supported with lazy.nvim. You get a startup warning and a config where the second spec list is silently ignored, which is a fun 40 minutes if you assumed the second call was the one doing the work.

Call setup() exactly once, from lazy.lua.


Part 2: Settings (lua/config/settings.lua)

Most “essential Neovim settings” lists are half redundant. Neovim already enables hidden, incsearch, hlsearch, autoindent, smarttab, and laststatus=2, and it turns on termguicolors automatically when it detects terminal support. Setting them again costs you nothing but it buries the six lines that actually matter.

Here is what actually differs from stock:

~/.config/nvim/lua/config/settings.lua
local opt = vim.opt
-- Tabs: Neovim ships tabstop=8, shiftwidth=8, noexpandtab
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.smartindent = true
-- Display
opt.number = true
opt.wrap = false
opt.cursorline = true
opt.signcolumn = "yes" -- stop the gutter jumping when diagnostics appear
-- Search: ignorecase alone is annoying, smartcase makes it usable
opt.ignorecase = true
opt.smartcase = true
-- Persistent undo across sessions, stored in ~/.local/state/nvim/undo
opt.undofile = true
-- Neovim defaults to mouse="nvi"; "a" adds command-line mode
opt.mouse = "a"
-- Requires a clipboard provider: wl-clipboard on Wayland,
-- xclip or xsel on X11, pbcopy/pbpaste on macOS. Check with :checkhealth
opt.clipboard = "unnamedplus"
-- Less UI noise
opt.showmode = false -- the statusline already says INSERT
opt.ruler = false

That clipboard line is the one that generates support questions. Without a provider binary on $PATH, yanking to the system clipboard fails quietly and you spend an afternoon blaming Neovim. :checkhealth provider tells you in one screen.

I dropped relativenumber and foldenable from the earlier version of this config. Relative line numbers are a real preference, not a default worth shipping, and folds are already off.


Part 3: Keymaps (lua/config/keymaps.lua)

This is where most configs quietly break LSP, so read the comments.

Since Neovim 0.11, these normal-mode mappings exist unconditionally, before any plugin loads: grn renames a symbol, grr lists references, gri jumps to implementation, gra runs a code action, grt goes to type definition, grx runs a code lens, gO lists document symbols. K maps to hover when a language server attaches, and CTRL-S in insert mode gives you signature help.

That means the common line map("n", "gr", vim.lsp.buf.references, ...) shadows the entire gr prefix. Every one of grn, grr, gri, gra, grt and grx stops working, and nothing tells you why. Same for remapping K: you replace hover with hover and gain nothing.

~/.config/nvim/lua/config/keymaps.lua
local map = vim.keymap.set
-- Disable arrow keys (yes, really, it forces muscle memory)
map("", "<Up>", "<Nop>")
map("", "<Down>", "<Nop>")
map("", "<Left>", "<Nop>")
map("", "<Right>", "<Nop>")
-- Splits and windows
map("n", "<Leader>v", "<C-w>v") -- vertical split
map("n", "<Leader>h", "<C-w>s") -- horizontal split
map("n", "<Leader>w", "<C-w>w") -- switch window
map("n", "<Leader>q", "<C-w>q") -- close window
-- Buffers
map("n", "<Leader>bn", "<cmd>bnext<CR>")
map("n", "<Leader>bp", "<cmd>bprevious<CR>")
map("n", "<Leader>bd", "<cmd>bdelete<CR>")
-- Clear search highlight. hlsearch is on by default in Neovim,
-- which is correct: you want the match visible, then gone on demand.
map("n", "<Esc>", "<cmd>nohlsearch<CR>")
-- Go-to-definition. This one Neovim does NOT map by default
-- (CTRL-] works via tagfunc, but gd is the muscle memory).
-- Bound on attach so it only exists in buffers with a live server.
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(ev)
map("n", "gd", vim.lsp.buf.definition, { buffer = ev.buf })
map("n", "gD", vim.lsp.buf.declaration, { buffer = ev.buf })
end,
})

vim.keymap.set already defaults to noremap = true, so the { noremap = true } you see in every older config is noise. Only the remap = true case needs an explicit option.

That is sixteen mappings on top of Neovim’s own. You don’t need 200.


Part 4: Plugin Manager Bootstrap (lua/config/lazy.lua)

~/.config/nvim/lua/config/lazy.lua
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
-- The string form tells lazy.nvim to import every module under
-- lua/plugins/. No index file, no manual require list.
require("lazy").setup("plugins", {
install = { colorscheme = { "tokyonight" } },
change_detection = { notify = false },
})

The string spec is the part people get wrong. The alternative you’ll see is a lua/plugins/init.lua that does this:

return {
require("plugins.lsp"),
require("plugins.telescope"),
require("plugins.colorscheme"),
}

It works, because lazy.nvim flattens nested spec tables. It also means every new plugin file needs an edit in two places, and forgetting the second edit produces a plugin that silently never installs. setup("plugins") walks the directory. Drop a file in, restart, done.


Part 5: Plugins (lua/plugins/)

lua/plugins/lsp.lua

This is the section that dates a Neovim config fastest. require("lspconfig").pyright.setup({}) was the standard call for years. As of nvim-lspconfig v2, that module is deprecated: on Neovim 0.11 and newer it emits a deprecation warning and it is scheduled for removal in v3.0.0. The upstream README is blunt about it. nvim-lspconfig itself is fine and still the right thing to install, but it is now a bag of server configs in an lsp/ directory that Neovim’s own vim.lsp.config picks up automatically.

The current API is two functions. vim.lsp.config(name, opts) merges your settings over the shipped config. vim.lsp.enable(name) turns it on for its filetypes.

~/.config/nvim/lua/plugins/lsp.lua
return {
{
"neovim/nvim-lspconfig",
event = { "BufReadPre", "BufNewFile" },
dependencies = { "hrsh7th/cmp-nvim-lsp" },
config = function()
-- Apply nvim-cmp's capabilities to every server at once
vim.lsp.config("*", {
capabilities = require("cmp_nvim_lsp").default_capabilities(),
})
-- Only servers that need extra settings get their own block
vim.lsp.config("lua_ls", {
settings = {
Lua = {
runtime = { version = "LuaJIT" },
diagnostics = { globals = { "vim" } },
},
},
})
-- Everything else just gets switched on
vim.lsp.enable({
"lua_ls",
"pylsp",
"ts_ls",
"rust_analyzer",
"gopls",
"jsonls",
"yamlls",
})
end,
},
{
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-nvim-lsp",
"hrsh7th/cmp-buffer",
"hrsh7th/cmp-path",
},
config = function()
local cmp = require("cmp")
cmp.setup({
mapping = cmp.mapping.preset.insert({
["<C-b>"] = cmp.mapping.scroll_docs(-4),
["<C-f>"] = cmp.mapping.scroll_docs(4),
["<C-Space>"] = cmp.mapping.complete(),
["<C-e>"] = cmp.mapping.abort(),
["<CR>"] = cmp.mapping.confirm({ select = true }),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
else
fallback()
end
end, { "i", "s" }),
}),
sources = cmp.config.sources({
{ name = "nvim_lsp" },
{ name = "buffer" },
{ name = "path" },
}),
})
end,
},
}

Note the server names: ts_ls, not tsserver, and lua_ls, not sumneko_lua. Both were renamed and the old names now warn. If you skip nvim-cmp entirely, Neovim sets omnifunc to the LSP completer on attach, so CTRL-X CTRL-O works with zero plugins.

Installing the servers

Neovim configures language servers. It does not install them. There is no :LspInstall in this config, because that command comes from Mason, which is not installed here. On purpose: your distro’s package manager already handles binaries, and one fewer plugin managing a shadow toolchain is one fewer thing to debug.

Terminal window
# macOS (Homebrew). The Python server formula is python-lsp-server,
# not pylsp; the pylsp binary is what it installs.
brew install lua-language-server python-lsp-server \
typescript-language-server gopls rust-analyzer
# Arch. rust-analyzer is its own package: the `rust` package only
# provides cargo and rustfmt.
sudo pacman -S lua-language-server python-lsp-server \
typescript-language-server gopls rust-analyzer
# Debian/Ubuntu
sudo apt install python3-pylsp gopls rust-analyzer nodejs npm
npm install -g typescript typescript-language-server
# lua-language-server is not in Debian. Grab a release binary from
# https://github.com/LuaLS/lua-language-server/releases and put it on $PATH.

Verify with :checkhealth vim.lsp, which reports every configured server, whether it attached, and where it looked for the root directory.

lua/plugins/telescope.lua

~/.config/nvim/lua/plugins/telescope.lua
return {
{
"nvim-telescope/telescope.nvim",
dependencies = { "nvim-lua/plenary.nvim" },
config = function()
local builtin = require("telescope.builtin")
vim.keymap.set("n", "<Leader>ff", builtin.find_files)
vim.keymap.set("n", "<Leader>fg", builtin.live_grep)
vim.keymap.set("n", "<Leader>fb", builtin.buffers)
vim.keymap.set("n", "<Leader>fh", builtin.help_tags)
end,
},
}

No tag = pin, and that is deliberate. The tag most configs copy is 0.1.8, released 24 May 2024, which predates Neovim 0.11 and its LSP changes. The repo also carries a v0.2.2 tag pointing at a September 2024 commit, so a version = "^0.2" range resolves backwards to older code than the v0.2.1 release from December 2025. Telescope’s own README supports the latest stable Neovim (0.11.7 and up) against its default branch. Track the branch.

<Leader>fg needs ripgrep installed. It is the one external dependency here worth checking before you file a bug.

lua/plugins/colorscheme.lua

~/.config/nvim/lua/plugins/colorscheme.lua
return {
{
"folke/tokyonight.nvim",
lazy = false,
priority = 1000,
config = function()
vim.cmd.colorscheme("tokyonight-night")
end,
},
}

priority = 1000 makes it load before everything else so you don’t watch the default scheme flash on startup.


Part 6: Actually Using This Thing

Step 1. Create the directories. Do not create lua/plugins/init.lua.

Terminal window
mkdir -p ~/.config/nvim/lua/config ~/.config/nvim/lua/plugins

Step 2. Copy the files above into their paths.

Step 3. Install the language servers for the languages you actually use.

Step 4. Start it. lazy.nvim clones itself, installs the plugins, and drops you into the dashboard. Roughly 30 seconds on a warm connection.

Terminal window
nvim

Step 5. Check your work:

If a server isn’t attaching, the answer is almost always one of three things: the binary isn’t on $PATH, the project has no root marker (pyproject.toml, go.mod, Cargo.toml, .git), or the file’s filetype isn’t what you think. :checkhealth vim.lsp prints all three.


Should You Use This?

Yes if: you want an editor you can explain to someone else. You’d rather read 120 lines than trust 2000. You spend more time in a terminal than a window manager. You’re on hardware where an Electron app is a real tax.

Probably not if: you need a debugger with a GUI (nvim-dap exists and it is not that). You aren’t comfortable on the command line. Your team is standardized on VS Code and the friction isn’t worth it.


What You Don’t Get


One Year Later

In a year you’ll have added maybe ten more plugins, learned enough Lua to write your own keymaps, and tuned LSP settings for the two languages you actually write. The config will still fit on a screen and a half.

That’s the point. Add one thing at a time, when it hurts. Two hundred lines you wrote and understand beats five hundred you inherited and can’t explain.

Your 2 AM self will appreciate you.

Common Questions

Do I need Mason to install language servers in Neovim?

No. Mason is convenient but optional. Your system package manager installs the same binaries, and Neovim’s vim.lsp.enable() finds anything on $PATH. Skipping Mason means one fewer plugin managing a parallel toolchain, and no :LspInstall command, which is a Mason command rather than a Neovim one.

Is require(‘lspconfig’).server.setup() still supported in 2026?

It works but it is deprecated. On Neovim 0.11 and newer, require('lspconfig') prints a deprecation warning and upstream has scheduled the module for removal in nvim-lspconfig v3.0.0. Migrate to vim.lsp.config() for settings and vim.lsp.enable() for activation. The plugin itself stays installed; only the old framework module goes away.

What Neovim version does this config need?

Neovim 0.11.3 or newer. Current nvim-lspconfig requires 0.11.3 and the vim.lsp.config API arrived in 0.11. Telescope’s default branch supports 0.11.7 and up. Neovim 0.12.5 was released on 23 August 2026 and is what these examples were written against. Neovim 0.10 configs will need the LSP section rewritten.

Why does gr stop working after I add LSP keymaps?

Because mapping gr in normal mode shadows Neovim’s built-in gr prefix. Since 0.11, Neovim maps grn for rename, grr for references, gri for implementation, gra for code action, grt for type definition, and grx for code lens. A gr mapping swallows all six. Bind a leader key instead.

How long does this Neovim setup actually take to configure?

About twenty minutes of copying files, plus however long your package manager needs for the language servers. The plugins install themselves on first launch in roughly 30 seconds. The week-long part is muscle memory for the keymaps, not configuration, and that clock starts the moment the config stops changing.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Kustomize Without Helm: When Overlays Win

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts