
Configuring Neovim from scratch with Lua and Lazy.nvim
Why Lua
Neovim 0.5, released in 2021, made Lua a first-class citizen. For a config built from scratch today, there's no reason to stay on Vimscript:
- Performance: Lua goes through LuaJIT and becomes bytecode. Vimscript stays interpreted.
- Modularity: you structure your config like real code, with modules and a directory tree that makes sense.
- Ecosystem: nearly every modern plugin is written in Lua, and the APIs are native.
- Readability: for anyone touching JS, Python or TypeScript, Lua is intuitive.
The config described here runs about 1,800 lines across 46 plugins and starts in under 35ms.
Project structure
~/.config/nvim/
├── init.lua # Entry point
├── lua/
│ ├── config/
│ │ ├── options.lua # Global Vim options
│ │ ├── keymaps.lua # Keyboard shortcuts
│ │ ├── lazy.lua # Lazy.nvim bootstrap
│ │ ├── autocmds.lua # Autocommands
│ │ └── icons.lua # Centralised icons
│ └── plugins/
│ ├── ui.lua # Theme, statusbar, bufferline
│ ├── editor.lua # Neo-tree, which-key, alpha
│ ├── lsp.lua # Native LSP + Mason
│ ├── completion.lua # Autocompletion
│ ├── treesitter.lua # Syntax highlighting
│ └── ...
init.lua is minimal — its only job is loading the modules in the right order:
-- init.lua
vim.loader.enable() -- Lua bytecode cache for faster startup
require("config.options")
require("config.keymaps")
require("config.lazy")
require("config.autocmds")
vim.loader.enable() turns on the Lua bytecode cache built into Neovim since 0.9: Lua files compile once and then come from cache. Roughly 30% off startup time.
The essential options
Everything centralised in options.lua rather than scattered vim.opt calls:
-- lua/config/options.lua
local opt = vim.opt
-- Disable netrw (replaced by Neo-tree)
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
-- Leaders
vim.g.mapleader = " "
vim.g.maplocalleader = ","
-- Line numbers
opt.number = true
opt.relativenumber = true
-- Indentation: 2 spaces, no tabs
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.smartindent = true
-- Smart search
opt.ignorecase = true
opt.smartcase = true
opt.hlsearch = true
-- Interface
opt.signcolumn = "yes"
opt.cursorline = true
opt.termguicolors = true
opt.showmode = false
-- Splits: open right and below
opt.splitright = true
opt.splitbelow = true
-- System clipboard
opt.clipboard = "unnamedplus"
-- Persistent undo, no swap
opt.undofile = true
opt.swapfile = false
-- Invisible characters
opt.list = true
opt.listchars = { tab = "» ", trail = "·", nbsp = "␣" }
-- Responsiveness
opt.updatetime = 250
opt.timeoutlen = 300
-- Scrolloff to keep context
opt.scrolloff = 10
A few choices worth explaining:
- Space as leader: the most accessible key with both hands on the keyboard. Paired with which-key, it makes for an ergonomic command system.
- relativenumber: essential for
5j,12kwithout counting lines. - 2 spaces: the de facto standard in the JS/TS/Lua world.
- No swap, persistent undo: swap files no longer earn their keep; cross-session undo does.
Bootstrapping Lazy.nvim
Lazy.nvim is the reference plugin manager: lazy-loading by default, simple config, and it self-installs on first launch.
-- lua/config/lazy.lua
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not 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)
require("lazy").setup("plugins", {
defaults = { lazy = true },
install = { colorscheme = { "catppuccin" } },
checker = { enabled = true, notify = false },
change_detection = { notify = false },
ui = { border = "rounded" },
})
require("lazy").setup("plugins") automatically loads every file in lua/plugins/. Each file returns a table (or a list of tables) describing plugins and their configuration. Lazy-loading by default means plugins only load when needed: on a command, on opening a file type, or on an event.
The theme: Catppuccin Mocha
Catppuccin in its Mocha variant: easy on the eyes over a long session, clean contrast, and integrations for practically every plugin in the ecosystem.
{
"catppuccin/nvim",
name = "catppuccin",
priority = 1000,
lazy = false,
opts = {
flavour = "mocha",
integrations = {
cmp = true,
gitsigns = true,
neotree = true,
treesitter = true,
telescope = { enabled = true },
which_key = true,
native_lsp = {
enabled = true,
underlines = {
errors = { "undercurl" },
warnings = { "undercurl" },
},
},
},
},
config = function(_, opts)
require("catppuccin").setup(opts)
vim.cmd.colorscheme("catppuccin")
end,
}
priority = 1000 and lazy = false guarantee the theme loads first, before the plugins that depend on it for their colours.
The interface
Lualine: the status bar
Lualine replaces the default statusline with useful content: Git branch, diffs, LSP errors, file type, position in file.
{
"nvim-lualine/lualine.nvim",
event = "VeryLazy",
opts = {
options = {
theme = "catppuccin",
component_separators = { left = "", right = "" },
section_separators = { left = "", right = "" },
},
sections = {
lualine_a = { "mode" },
lualine_b = { "branch", "diff", "diagnostics" },
lualine_c = { "filename" },
lualine_x = { "encoding", "fileformat", "filetype" },
lualine_y = { "progress" },
lualine_z = { "location" },
},
},
}
Bufferline: buffer tabs
Bufferline shows open buffers as tabs, with a close icon on hover, an error count per buffer, and keyboard navigation.
{
"akinsho/bufferline.nvim",
event = "VeryLazy",
opts = {
options = {
diagnostics = "nvim_lsp",
close_icon = "",
buffer_close_icon = "",
modified_icon = "●",
offsets = {
{ filetype = "neo-tree", text = "File Explorer", highlight = "Directory" },
},
},
},
}
Neo-tree: the file explorer
Neo-tree replaces netrw. Toggled on <leader>n, with Git status shown directly in the tree:
{
"nvim-neo-tree/neo-tree.nvim",
cmd = "Neotree",
keys = {
{ "<leader>n", "<cmd>Neotree toggle<cr>", desc = "Toggle file explorer" },
},
opts = {
filesystem = {
follow_current_file = { enabled = true },
use_libuv_file_watcher = true,
},
window = {
width = 35,
mappings = { ["<space>"] = "none" },
},
default_component_configs = {
git_status = {
symbols = {
added = "✚",
modified = "",
deleted = "✖",
renamed = "",
untracked = "",
},
},
},
},
}
Alpha: the start dashboard
Launched with no argument, Alpha shows an ASCII logo and shortcuts to frequent actions.
{
"goolord/alpha-nvim",
event = "VimEnter",
config = function()
local alpha = require("alpha")
local dashboard = require("alpha.themes.dashboard")
dashboard.section.header.val = {
" ",
" ███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗",
" ████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║",
" ██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║",
" ██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║",
" ██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║",
" ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝",
}
dashboard.section.buttons.val = {
dashboard.button("r", " Recent files", "<cmd>Telescope oldfiles<cr>"),
dashboard.button("n", " New file", "<cmd>ene<cr>"),
dashboard.button("f", " Find a file", "<cmd>Telescope find_files<cr>"),
dashboard.button("g", " Grep in files", "<cmd>Telescope live_grep<cr>"),
dashboard.button("c", " Configuration", "<cmd>e $MYVIMRC<cr>"),
dashboard.button("l", " Lazy", "<cmd>Lazy<cr>"),
dashboard.button("q", " Quit", "<cmd>qa<cr>"),
}
alpha.setup(dashboard.config)
end,
}
Which-key: the shortcut guide
Which-key transforms keybinding discoverability: press Space, wait half a second, and a popup lists every shortcut, organised. A permanent cheat sheet that takes no screen space.
Startup time
46 plugins, well-configured lazy-loading, 35ms at startup — verifiable with :Lazy profile. Compared with the 2 to 5 seconds of a typical GUI editor: when you open and close your editor dozens of times a day, that difference matters.
Conclusion
Building a Neovim configuration from scratch is a real investment. But once the structure is in place — the folders, init.lua, the config/ modules — every addition becomes trivial: create a Lua file in plugins/, add a table, done.
The result is an editor specialised for your own way of working, with no dead weight, that starts instantly and whose every layer you understand. Lua and Lazy.nvim make that accessible even without ever having mastered Vimscript.
Related articles