Init.lua Structure
The init.lua file is the entry point for your Neovim configuration. A well-organized structure makes your config maintainable, readable, and portable.
Learning Focus
Build the config structure first (empty modules), then fill each file. Never put everything in one file.
The Recommended Directory Structure
~/.config/nvim/
├── init.lua ← entry point, loads everything
└── lua/
├── config/
│ ├── options.lua ← vim.opt settings
│ ├── keymaps.lua ← vim.keymap.set
│ └── autocmds.lua ← autocommands
└── plugins/
├── init.lua ← lazy.nvim setup + plugin list
├── lsp.lua ← LSP configuration
├── telescope.lua ← Telescope config
├── completion.lua ← nvim-cmp config
├── treesitter.lua ← treesitter config
├── ui.lua ← theme, statusline, etc.
└── git.lua ← git plugins
The Entry Point: init.lua
~/.config/nvim/init.lua
-- Load core config modules
require("config.options")
require("config.keymaps")
require("config.autocmds")
-- Bootstrap and load plugins
require("plugins")
That's it — the entry point just loads modules. All the substance is in the modules.
How Lua Modules Work in Neovim
require("config.options") loads ~/.config/nvim/lua/config/options.lua.
The path mapping is:
require("config.options") → lua/config/options.lua
require("plugins.lsp") → lua/plugins/lsp.lua
require("utils.helpers") → lua/utils/helpers.lua
Lua uses the package.path and Neovim adds lua/ to the search path automatically.
Creating the Directory Structure
mkdir -p ~/.config/nvim/lua/config
mkdir -p ~/.config/nvim/lua/plugins
touch ~/.config/nvim/init.lua
touch ~/.config/nvim/lua/config/{options,keymaps,autocmds}.lua
touch ~/.config/nvim/lua/plugins/init.lua
The Options Module
~/.config/nvim/lua/config/options.lua
local opt = vim.opt
-- Line numbers
opt.number = true -- show line numbers
opt.relativenumber = true -- show relative line numbers
-- Indentation
opt.tabstop = 2 -- tab width
opt.shiftwidth = 2 -- indent width
opt.softtabstop = 2
opt.expandtab = true -- spaces instead of tabs
opt.autoindent = true
opt.smartindent = true
-- Search
opt.ignorecase = true
opt.smartcase = true -- case-sensitive if uppercase
opt.incsearch = true
opt.hlsearch = true
-- Appearance
opt.termguicolors = true -- true color support
opt.signcolumn = "yes" -- always show sign column
opt.cursorline = true -- highlight current line
opt.wrap = false -- no line wrapping
opt.scrolloff = 8 -- keep 8 lines above/below cursor
opt.sidescrolloff = 8
-- Files
opt.encoding = "utf-8"
opt.fileencoding = "utf-8"
opt.undofile = true -- persistent undo
opt.swapfile = false
opt.backup = false
opt.updatetime = 300 -- faster CursorHold events
-- Performance
opt.timeoutlen = 300 -- key sequence timeout (ms)
opt.ttimeoutlen = 0
-- Completion
opt.completeopt = { "menu", "menuone", "noselect" }
opt.pumheight = 10 -- max popup menu items
-- Split behavior
opt.splitright = true -- vertical splits go right
opt.splitbelow = true -- horizontal splits go below
-- Clipboard
opt.clipboard = "unnamedplus" -- sync with system clipboard
The Keymaps Module
~/.config/nvim/lua/config/keymaps.lua
local map = vim.keymap.set
-- Set leader key (do this before plugins load)
vim.g.mapleader = " " -- space as leader
vim.g.maplocalleader = "\\" -- backslash as local leader
-- Better window navigation
map("n", "<C-h>", "<C-w>h", { desc = "Move to left window" })
map("n", "<C-l>", "<C-w>l", { desc = "Move to right window" })
map("n", "<C-j>", "<C-w>j", { desc = "Move to bottom window" })
map("n", "<C-k>", "<C-w>k", { desc = "Move to top window" })
-- Resize windows
map("n", "<C-Up>", ":resize -2<CR>", { desc = "Resize window up" })
map("n", "<C-Down>", ":resize +2<CR>", { desc = "Resize window down" })
map("n", "<C-Left>", ":vertical resize -2<CR>", { desc = "Resize window left" })
map("n", "<C-Right>", ":vertical resize +2<CR>", { desc = "Resize window right" })
-- Buffer navigation
map("n", "<S-l>", ":bnext<CR>", { desc = "Next buffer" })
map("n", "<S-h>", ":bprev<CR>", { desc = "Prev buffer" })
map("n", "<leader>bd", ":bdelete<CR>", { desc = "Delete buffer" })
-- Stay in indent mode (keep selection after indent)
map("v", "<", "<gv", { desc = "Indent left" })
map("v", ">", ">gv", { desc = "Indent right" })
-- Move lines up/down
map("n", "<A-j>", ":m .+1<CR>==", { desc = "Move line down" })
map("n", "<A-k>", ":m .-2<CR>==", { desc = "Move line up" })
map("v", "<A-j>", ":m '>+1<CR>gv=gv", { desc = "Move selection down" })
map("v", "<A-k>", ":m '<-2<CR>gv=gv", { desc = "Move selection up" })
-- Clear search highlight
map("n", "<Esc>", ":nohlsearch<CR>", { desc = "Clear highlights" })
-- Better paste (don't overwrite clipboard when pasting over selection)
map("v", "p", '"_dP', { desc = "Paste without yank" })
-- Save
map("n", "<leader>w", ":w<CR>", { desc = "Save file" })
map("n", "<leader>q", ":q<CR>", { desc = "Quit" })
map("n", "<leader>Q", ":qa!<CR>", { desc = "Force quit all" })
-- Open config
map("n", "<leader>ec", ":e ~/.config/nvim/init.lua<CR>", { desc = "Edit config" })
The Autocmds Module
~/.config/nvim/lua/config/autocmds.lua
local autocmd = vim.api.nvim_create_autocmd
local augroup = vim.api.nvim_create_augroup
-- Highlight on yank
autocmd("TextYankPost", {
group = augroup("highlight_yank", { clear = true }),
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 200 })
end,
})
-- Remove trailing whitespace on save
autocmd("BufWritePre", {
group = augroup("trim_whitespace", { clear = true }),
pattern = "*",
callback = function()
local pos = vim.api.nvim_win_get_cursor(0)
vim.cmd([[%s/\s\+$//e]])
vim.api.nvim_win_set_cursor(0, pos)
end,
})
-- Return to last edit position
autocmd("BufReadPost", {
group = augroup("last_position", { clear = true }),
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local line_count = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= line_count then
vim.api.nvim_win_set_cursor(0, mark)
end
end,
})
-- Resize splits on window resize
autocmd("VimResized", {
group = augroup("equalize_splits", { clear = true }),
callback = function()
vim.cmd("tabdo wincmd =")
end,
})
-- Auto-format on save (if formatter configured)
autocmd("BufWritePre", {
group = augroup("auto_format", { clear = true }),
pattern = { "*.lua", "*.js", "*.ts", "*.py" },
callback = function()
vim.lsp.buf.format({ async = false })
end,
})
Verifying Your Config Loads
nvim --headless -c "lua print(vim.o.number)" -c "quit"
# Should print: true
# Check for errors on startup
nvim --startuptime /tmp/nvim-startup.log
cat /tmp/nvim-startup.log