How I Structure My Neovim Configuration by Concern
How I structure my Neovim configuration by concern, splitting options, keymaps, and autocommands into separate modules behind one entry point, with one ordering dependency that matters.
How I structure my Neovim configuration by concern, splitting options, keymaps, and autocommands into separate modules behind one entry point, with one ordering dependency that matters.
This post covers the base of my Neovim configuration, the part that is Neovim
itself. It is split by reason for change into three concerns, options, keymaps,
and autocommands, and init.lua is the entry point that composes them. One
ordering dependency ties the base together. The leader must be set before
keymaps are created. Nothing in the base comes from a plugin. The plugin layer,
loaded by vim.pack, is a separate subject with its own post. This is the
configuration I am carrying into 2027, and every layer above it builds on this
foundation.
The base is structured around four paths, init.lua, lua/config/options.lua,
lua/config/keymaps/, and lua/config/autocmds/. init.lua sits at the root
and does the loading. The other three live under lua/config/, one file for
options and one directory each for keymaps and autocommands. Each directory
holds an aggregator plus files split by concern.
All of this is a personal setup on one machine, macOS on Apple Silicon with a
recent stable Neovim from Homebrew, and ~/.config/nvim is a Stow symlink into
~/env/shared along with my shell and terminal configs. Version-sensitive lines
are called out where they appear.
The base of the Neovim setup is four paths. init.lua loads three modules with
setup() and lua/config/ holds a file of options, a directory of keymaps, and
a directory of autocommands. None of it needs a plugin.
There is one ordering dependency in the base. The leader must be set before keymaps are created. The autocommands sit last and depend on nothing.
Neovim evaluates ~/.config/nvim/init.lua at startup on macOS. Mine is a
loader. The whole base reduces to three calls.
require('config.options').setup()
require('config.keymaps').setup()
require('config.autocmds').setup()
Everything that makes the editor behave is delegated out, which is what keeps
init.lua a loader instead of a config file. The real file continues below
these three lines with the plugin setup, which is outside this post’s scope and
is not needed to understand any of this.
Each require resolves to a file under lua/ in the config directory. Neovim
adds every lua/ directory on its runtimepath to the Lua module search path,
and dots in a module name become separators. require('config.options') loads
lua/config/options.lua, and require('config.keymaps') loads
lua/config/keymaps/init.lua, because requiring a directory runs its
init.lua. So module names match the file layout exactly, and I never have to
think about where a require goes.
Each of these modules returns a table with the same setup() convention. A bare
require loads the file but runs nothing, since the functions inside only get
defined. Calling .setup() is what makes the module do its work, and it keeps
one entry point across every level of the configuration.
Options were the first thing to leave init.lua when the configuration outgrew
one file, because they are the settings I touch most. The module groups them by
area, one function per concern.
local M = {}
function M.leader()
vim.g.mapleader = ' '
end
function M.ui()
vim.opt.mouse = ''
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.wrap = false
vim.opt.termguicolors = true
vim.opt.laststatus = 1
vim.opt.conceallevel = 0
vim.opt.signcolumn = 'yes:1'
vim.opt.scrolloff = 8
vim.opt.tabstop = 2
vim.opt.winborder = 'rounded'
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.opt.smartindent = true
end
function M.behavior()
vim.opt.list = false
vim.opt.spelllang = { 'en' }
vim.opt.splitright = true
vim.opt.splitbelow = true
end
function M.search()
vim.opt.hlsearch = true
vim.opt.ignorecase = true
vim.opt.inccommand = 'split'
vim.opt.smartcase = true
end
function M.shell()
vim.opt.shell = vim.env.SHELL or 'zsh'
vim.opt.clipboard = vim.env.SSH_TTY and '' or 'unnamedplus'
vim.opt.completeopt = 'menu,menuone,noselect'
end
function M.misc()
vim.opt.formatoptions:append('r')
vim.opt.path:append('**')
vim.opt.wildignore:append('*/node_modules/*')
vim.opt.wildmode = 'longest:full,full'
vim.opt.backspace = { 'start', 'eol', 'indent' }
vim.opt.backup = false
vim.opt.swapfile = false
vim.opt.shortmess:append('WI')
vim.g.deprecation_warnings = true
end
function M.setup()
M.leader()
M.ui()
M.behavior()
M.search()
M.shell()
M.misc()
end
return M
M.setup() runs the domain functions in a deliberate order. M.leader() comes
first because it assigns vim.g.mapleader, and <leader> in a mapping is
expanded when the mapping is created, not when it is pressed. The keymaps run
later, so the value has to exist before they do. The rest of setup() is just
the order I want the settings applied.
The assignments themselves are plain vim.opt lines grouped by what they
change. ui() owns numbers, wrapping, and the window. search() owns how
searches behave. shell() owns what child processes see. misc() owns file
handling. When I am looking for one setting I open the one function and nothing
else in the file.
A few entries deserve a closer look. relativenumber displays the absolute line
number on the current line and relative numbers everywhere else, so one option
covers both. signcolumn = 'yes:1' holds the sign column at a fixed
one-character width, which keeps diagnostics and marks from nudging the text
sideways. inccommand = 'split' shows substitution matches live in a split, and
winborder = 'rounded' styles floating windows and needs a recent Neovim, so on
an older install that single line can be removed without consequence.
Two of the functions carry decisions worth naming. In search(), ignorecase
with smartcase makes searches case-insensitive only while you type lowercase,
and an uppercase letter reverts to exact matching. hlsearch keeps the last
match highlighted until the <leader>cs mapping clears it. In shell(), the
editor runs with the shell from the environment, zsh as the fallback, so
commands invoked through :! match the shell I work in, and the clipboard line
enables unnamedplus locally while leaving the option empty over SSH, because
there is no local clipboard to use on a remote session.
splitright and splitbelow open new splits to the right and below, so the
buffer I am working in stays put. misc() turns off backup and swap files, so
the editor leaves no artifacts next to the files it opens. The trade is that a
hard crash can lose an unsaved buffer with no swap file to recover from.
Mouse is off, matching the tmux config. The line sets the option directly,
vim.opt.mouse = '', and Neovim’s current default is the opposite, mouse on
across all modes, so this line actually changes behavior rather than documenting
intent.
Keymaps have their own directory because that is where I look when a key does the wrong thing. The structure mirrors the rest of the base, an aggregator that lists two files.
lua/config/keymaps/init.lua is that aggregator.
local M = {}
function M.setup()
require('config.keymaps.general').setup()
require('config.keymaps.diagnostics').setup()
end
return M
general.lua holds the everyday keys.
local M = {}
function M.setup()
-- Exit insert mode with jk
vim.keymap.set('i', 'jk', '<ESC>')
-- Increment and decrement numbers
vim.keymap.set('n', '<leader>+', '<C-a>')
vim.keymap.set('n', '<leader>-', '<C-x>')
-- Clear search highlights
vim.keymap.set('n', '<leader>cs', vim.cmd.nohlsearch)
-- Save and source current file
vim.keymap.set('n', '<leader>us', ':update<CR> :source<CR>')
end
return M
jk is the insert-mode escape, two keys under the home row instead of a reach.
The leader-prefixed plus and minus run the stock increment and decrement
commands. <leader>cs clears search highlights through vim.cmd.nohlsearch.
<leader>us writes and sources the current file, which is the loop I use to
apply edits to this configuration as I make them. Sourcing the aggregators or
init.lua reruns each setup(), which is why the autocommands below are
idempotent.
diagnostics.lua lives apart because its bindings come from vim.diagnostic,
part of Neovim core, and they stay useful once a language server attaches.
setqflist fills the quickfix list with the current buffer’s diagnostics, and
setloclist fills the current window’s location list instead.
local M = {}
function M.setup()
-- Send diagnostics to quickfix list
vim.keymap.set('n', '<leader>xq', vim.diagnostic.setqflist)
-- Send diagnostics to location list
vim.keymap.set('n', '<leader>xl', vim.diagnostic.setloclist)
-- Toggle diagnostic virtual text
vim.keymap.set('n', '<leader>xt', function()
local current_status = vim.diagnostic.config().virtual_text
vim.diagnostic.config({ virtual_text = not current_status })
-- Uncomment vim.notify for toggle feedback
-- vim.notify(
-- 'Diagnostics virtual text: ' .. (current_status and 'OFF' or 'ON')
-- )
end)
end
return M
The lists these bindings fill are empty before any language server is attached,
and that is fine, because the bindings are already in place when the data shows
up. The <leader>xt toggle reads whether virtual text is currently enabled,
flips it, and reapplies the diagnostic config. The vim.notify feedback is
commented out, which I consider the quieter choice.
None of these mappings carry a desc. Nothing in the base setup displays
mapping descriptions yet, and a comment per map already says what each one does.
The autocommand directory holds two events behind an aggregator.
lua/config/autocmds/init.lua is that aggregator.
local M = {}
function M.setup()
require('config.autocmds.highlight_yank').setup()
require('config.autocmds.text_wrap_spell').setup()
end
return M
highlight_yank.lua handles the yank highlight.
local M = {}
function M.setup()
vim.api.nvim_create_autocmd('TextYankPost', {
group = vim.api.nvim_create_augroup('YankHighlight', { clear = true }),
callback = function()
-- TODO: Replace vim.hl.on_yank() with vim.hl.hl_op() when Neovim 0.13 is released.
vim.hl.on_yank({ timeout = 150 })
end,
})
end
return M
TextYankPost fires after an operation writes text to a register. The callback
calls vim.hl.on_yank, another piece of Neovim core, so the yank flash needs no
plugin. The TODO inside notes the planned move to vim.hl.hl_op once Neovim
0.13 releases, and this post targets a current stable where that swap has not
happened yet.
text_wrap_spell.lua turns on wrap and spell for text files.
local M = {}
function M.setup()
-- Enable wrap and spell for text files
vim.api.nvim_create_autocmd('FileType', {
group = vim.api.nvim_create_augroup('TextWrapSpell', { clear = true }),
pattern = {
'gitcommit',
'markdown',
'text',
},
callback = function()
vim.opt_local.wrap = true
vim.opt_local.spell = true
end,
})
end
return M
FileType fires each time a filetype is detected. Matching three patterns, git
commits, Markdown, and plain text, the callback turns on wrap and spell. The
settings are applied with vim.opt_local, which changes the option for the
current buffer only. That contrast with vim.opt in options.lua is why these
rules are autocommands in the first place, since code buffers should never
inherit them.
Both autocommands register under a named augroup with clear = true. Without a
group, re-running setup() would stack a second copy of each callback and both
would fire. clear = true empties the group first, so reloading the base
mid-session does not double up.
Across the base there is one ordering dependency. The leader must be set before
keymaps are created. setup() in init.lua already runs the modules in that
order, so the constraint is enforced by the file layout rather than by a
comment.
The autocommands sit last because they read nothing at load time. Their
callbacks run on events, so their position in init.lua is a formality.
Copy the four paths and this is what the config directory looks like.
~/.config/nvim/
├── init.lua
└── lua/
└── config/
├── autocmds/
│ ├── init.lua
│ ├── highlight_yank.lua
│ └── text_wrap_spell.lua
├── keymaps/
│ ├── init.lua
│ ├── general.lua
│ └── diagnostics.lua
└── options.lua
Creating these files and launching Neovim produces the setup as described. The editor starts with the base options applied, the bound keys active, and both autocommands running. Nothing here requires a plugin to be installed.
On this machine the whole tree is a Stow symlink into ~/env/shared, so every
file in it is versioned with the rest of the environment.
Everything above would also work from a single init.lua. Neovim does not care
how many files the configuration spans, and one file is easier to diff. The
split is not a correctness requirement.
It earns its place when I edit. A key that misbehaves points at
keymaps/general.lua before I have opened anything. A window that looks wrong
points at one named function inside options.lua. The git history of each
directory reads as the history of one concern, and small diffs are a side effect
of that rather than a goal.
The trade is a taller module graph. Every concern needs a path, and every file
needs its setup() call to trace. For a configuration this size I have decided
that is worth it, and the fixed order at the top of init.lua means the mental
model does not grow with the files.
This is a small configuration that stays small for a specific reason. Options, keymaps, and autocommands each know where they live, and the file boundaries match the reasons I edit them. The cost is a handful of extra files and one extra call per module, which is cheap next to never having to re-derive the layout from scratch. That is the trade, and it is the whole story of the structure.
Plugins are a separate subject, and the configuration that loads them has its own post. Everything in this post runs on Neovim alone.