Configuring Neovim from scratch with Lua and Lazy.nvim
- Published on
- ·9 min read
Why Lua?
Vimscript? Sure, it worked. Like, really worked back in the day. But Neovim 0.5 dropped in 2021 and everything shifted. Lua became a proper first-class citizen, and honestly, it's 2026 now. Nobody's got an excuse to keep writing Vimscript for a fresh config.
The wins are obvious:
- Performance: Lua becomes bytecode via LuaJIT. Vimscript stays interpreted. Period.
- Modularity: you actually structure things like real code. Modules, imports, a filesystem that makes sense.
- Ecosystem: nearly every plugin worth using is written in Lua. The APIs are native. No translation layer.
- Readability: if you write JS, Python, or TypeScript at work, Lua just clicks. No weird learning curve.
I've been living in Neovim as my daily editor for web stuff (JS/TS, Python, some Lua) for years now. My setup is roughly 1800 lines of Lua across 46 different plugins (yeah, I like plugins). And it launches in under 35ms. Here's how I organized it.
Project structure
The organization follows a classic pattern in the Neovim ecosystem:
~/.config/nvim/
├── init.lua # Entry point
├── lua/
│ ├── config/
│ │ ├── options.lua # Global Vim options
│ │ ├── keymaps.lua # Key mappings
│ │ ├── lazy.lua # Lazy.nvim bootstrap
│ │ ├── autocmds.lua # Autocommands
│ │ └── icons.lua # Centralized 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
│ └── ...
The init.lua is minimal. Its role is to load modules in the right order:
-- init.lua
vim.loader.enable() -- Cache Lua bytecode for faster startup
require("config.options")
require("config.keymaps")
require("config.lazy")
require("config.autocmds")
That first line matters: vim.loader.enable(). It flips on the Lua bytecode cache that's been baked into Neovim since like 0.9. What that means in practice? Your Lua files get compiled once, then they load from cache. I actually measured this. Saved me about 30% on startup time.
Essential options
The options.lua file contains all global options. I prefer centralizing everything here rather than scattering vim.opt calls across different files:
-- 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
-- Scroll offset for context
opt.scrolloff = 10
Some choices worth explaining:
- Space as leader: yeah, the spacebar. It's the most accessible key when both hands are on the keyboard. Combine that with which-key and you've got a command system that actually feels ergonomic.
- relativenumber: absolutely essential for doing
5j,12kwithout counting on your fingers. You see exactly how many lines you need to jump. - 2 spaces: it's the de facto standard in the JS/TS/Lua world. I tried 4 spaces once. Never again. Too much visual noise.
- No swap, persistent undo: swap files are ancient history. Persistent undo between sessions? That's the move. Actually useful.
Lazy.nvim bootstrap
Lazy.nvim is the standard now. I used Packer before. Tried vim-plug for like a week (big mistake). Lazy is solid - lazy-loading by default, clean API, does exactly what you ask without drama.
The cool part? It self-installs on first run:
-- 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" },
})
The require("lazy").setup("plugins") call automatically loads all files from the lua/plugins/ directory. Each file returns a table (or a list of tables) describing plugins and their configuration. Default lazy-loading means plugins are only loaded when needed: when a command is triggered, a specific filetype is opened, or an event fires.
The theme: Catppuccin Mocha
I've tried too many color schemes, honestly. Gruvbox? Too warm after 8 hours. Tokyo Night? Pretty but inconsistent. One Dark? Mid.
Then I found Catppuccin in Mocha.
It was a lightbulb moment. The colors actually rest your eyes. The contrast is clean. And - this is the important bit - it has integrations for every plugin. Even the obscure ones. Here's the setup:
{
"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,
}
The priority = 1000 and lazy = false ensure the theme loads first, before other plugins that depend on it for their colors.
The complete interface
Lualine: the status bar
Lualine. Before, the default statusline was... basic. Soulless, really. Lualine replaces it with actual content: your Git branch, diffs, LSP errors, file type, cursor position. All in one line that looks good. I set it up with Catppuccin and rounded separators:
{
"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 - your open buffers as tabs at the top. It seems simple, almost obvious, but once you have it, you keep it. Close icon on hover. Error count per buffer. Quick navigation. Easy wins:
{
"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 the old netrw with a modern interface. I toggle it with <leader>n and it shows Git status directly in the file 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 startup dashboard
When launching Neovim without arguments, Alpha displays a dashboard with an ASCII logo and shortcuts to frequent actions: recent files, new file, search, configuration, and more.
{
"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 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 keybinding guide
Which-key changes everything about keybinding discoverability. Press Space, wait half a second, boom - a popup with all your bindings, nicely organized. It's like having a permanent cheat sheet embedded in your editor, except it takes zero screen real estate.
Startup time
So. 46 plugins. Lazy-loading dialed in. Boots in 35ms. Faster than your terminal's prompt. I didn't make this up - I ran :Lazy profile like fifty times to verify. Spoiler: it's true.
VS Code? 2-5 seconds. Not gonna trash-talk VS Code (it's legitimate), but when you open/close your editor dozens of times a day, that difference matters.
The wrap-up
Building a Neovim config from zero takes real time. Yeah. It's an actual investment, not a quick afternoon project. But once you've got the structure down - the folders, the init.lua, the config/ modules - everything after that is trivial. Just drop a Lua file in plugins/, add a table, and you're done.
In the end, you get an editor that's hyper-specialized for how you actually work. No bloat. No features you don't use. Starts in milliseconds. And you understand every layer because you either wrote it or you copy-pasted a snippet and bothered to read the docs.
That's the real win. Lua and Lazy.nvim make this whole thing accessible, even if you've never actually mastered Vimscript.
Neovim from scratch series — This article is part of a complete series on configuring Neovim from scratch.
Next: Native LSP in Neovim 0.11: zero plugins, zero compromises
Previous post
← Installing Debian on a NAS in fully automated mode