Back to blog

Modern Neovim from Scratch: Lua Modular Architecture, Lazy.nvim, LSP, and Treesitter

A practical guide to configuring a fast, modular Neovim development environment using Lua, Lazy.nvim, native LSP with Mason, nvim-cmp, and Treesitter.

September 12, 2023Updated September 08, 2026

Vim configurations historically relied on monolithic init.vim files written in Vimscript, external node processes like coc.nvim, and plugin managers like vim-plug.

Neovim v0.9+ replaces all three with a native Lua runtime, an embedded Language Server Protocol (LSP) client, and syntax parsing through Tree-sitter.

The config below is split across five files so that a broken plugin is a broken file, not a broken editor.

plaintext
~/.config/nvim/
├── init.lua
└── lua/
    ├── config/
    │   ├── options.lua    # Editor defaults and UI behaviors
    │   ├── keymaps.lua    # Keybindings and ergonomics
    │   └── lazy.lua       # Lazy.nvim bootstrapper
    └── plugins/
        ├── lsp.lua        # Mason, nvim-lspconfig, and nvim-cmp
        ├── treesitter.lua # AST syntax parsing and highlighting
        ├── telescope.lua  # Fuzzy finding and ripgrep search
        └── colorscheme.lua

Step 1: Set the entry point and editor defaults

~/.config/nvim/init.lua is the entry point. It does nothing except require the modules under lua/.

lua
-- ~/.config/nvim/init.lua
require("config.options")
require("config.keymaps")
require("config.lazy")

Editor options (lua/config/options.lua)

Define standard buffer and window behavior using vim.opt:

lua
-- ~/.config/nvim/lua/config/options.lua
local opt = vim.opt
 
-- Line numbers
opt.number = true
opt.relativenumber = true
 
-- Tabs and Indentation
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.autoindent = true
opt.smartindent = true
 
-- Search behavior
opt.ignorecase = true
opt.smartcase = true
 
-- Visuals and Splits
opt.termguicolors = true
opt.signcolumn = "yes"
opt.scrolloff = 8
opt.splitright = true
opt.splitbelow = true
opt.cursorline = true
 
-- System integration
opt.clipboard = "unnamedplus"
opt.undofile = true -- Persistent undo history across editor restarts
opt.updatetime = 250
opt.timeoutlen = 300

Ergonomic keymaps (lua/config/keymaps.lua)

Set the spacebar as the leader key and map window, buffer, and navigation shortcuts:

lua
-- ~/.config/nvim/lua/config/keymaps.lua
vim.g.mapleader = " "
vim.g.maplocalleader = " "
 
local map = vim.keymap.set
 
-- Clear search highlights on pressing Escape in normal mode
map("n", "<Esc>", "<cmd>nohlsearch<CR>")
 
-- Window navigation using Ctrl + hjkl
map("n", "<C-h>", "<C-w>h", { desc = "Move to left window" })
map("n", "<C-j>", "<C-w>j", { desc = "Move to lower window" })
map("n", "<C-k>", "<C-w>k", { desc = "Move to upper window" })
map("n", "<C-l>", "<C-w>l", { desc = "Move to right window" })
 
-- Keep cursor centered during half-page scrolling and search jumps
map("n", "<C-d>", "<C-d>zz")
map("n", "<C-u>", "<C-u>zz")
map("n", "n", "nzzzv")
map("n", "N", "Nzzzv")
 
-- Move selected text blocks up and down in visual mode
map("v", "J", ":m '>+1<CR>gv=gv")
map("v", "K", ":m '<-2<CR>gv=gv")
 
-- Buffer management
map("n", "<leader>bd", "<cmd>bdelete<CR>", { desc = "Delete buffer" })
map("n", "[b", "<cmd>bprevious<CR>", { desc = "Previous buffer" })
map("n", "]b", "<cmd>bnext<CR>", { desc = "Next buffer" })

Step 2: Bootstrap package management with Lazy.nvim

lazy.nvim provides fast plugin loading with automatic lazy-loading on keymaps, filetypes, or events.

Create lua/config/lazy.lua to clone and initialize the package manager automatically:

lua
-- ~/.config/nvim/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({
  spec = {
    { import = "plugins" },
  },
  defaults = {
    lazy = true,
  },
  install = {
    colorscheme = { "tokyonight", "habamax" },
  },
})

Step 3: Replace regex highlighting with Tree-sitter

Traditional Vim syntax highlighting relies on regular expressions, which can be slow and fail to parse complex nested language scopes.

Tree-sitter generates a concrete syntax tree (AST) directly in memory, updating incrementally on every keystroke:

lua
-- ~/.config/nvim/lua/plugins/treesitter.lua
return {
  "nvim-treesitter/nvim-treesitter",
  build = ":TSUpdate",
  event = { "BufReadPost", "BufNewFile" },
  config = function()
    require("nvim-treesitter.configs").setup({
      ensure_installed = {
        "c", "cpp", "lua", "vim", "vimdoc",
        "python", "rust", "go", "typescript",
        "tsx", "javascript", "html", "css", "json",
      },
      auto_install = true,
      highlight = {
        enable = true,
        additional_vim_regex_highlighting = false,
      },
      indent = {
        enable = true,
      },
    })
  end,
}

Step 4: Wire fuzzy finding to ripgrep with Telescope

Telescope uses ripgrep and fd to scan files, git status, and code symbols:

lua
-- ~/.config/nvim/lua/plugins/telescope.lua
return {
  "nvim-telescope/telescope.nvim",
  tag = "0.1.6",
  dependencies = {
    "nvim-lua/plenary.nvim",
    {
      "nvim-telescope/telescope-fzf-native.nvim",
      build = "make",
      cond = function()
        return vim.fn.executable("make") == 1
      end,
    },
  },
  cmd = "Telescope",
  keys = {
    { "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find Files" },
    { "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Live Grep" },
    { "<leader>fb", "<cmd>Telescope buffers<CR>", desc = "Find Buffers" },
    { "<leader>fh", "<cmd>Telescope help_tags<CR>", desc = "Help Tags" },
  },
  config = function()
    local telescope = require("telescope")
    telescope.setup({
      defaults = {
        path_display = { "truncate" },
        file_ignore_patterns = { "node_modules", ".git/" },
      },
    })
    pcall(telescope.load_extension, "fzf")
  end,
}

Step 5: Attach native LSP and autocompletion

Language Server Protocol integration requires three coordinating layers:

  1. mason.nvim: Manages language server binaries (gopls, pyright, ts_ls, rust-analyzer).
  2. nvim-lspconfig: Configures client options and hooks keymaps on server attachment (on_attach).
  3. nvim-cmp: Renders autocompletion popups, snippet expansions, and signature help.
lua
-- ~/.config/nvim/lua/plugins/lsp.lua
return {
  -- LSP Configuration
  {
    "neovim/nvim-lspconfig",
    event = { "BufReadPre", "BufNewFile" },
    dependencies = {
      "williamboman/mason.nvim",
      "williamboman/mason-lspconfig.nvim",
      "hrsh7th/cmp-nvim-lsp",
    },
    config = function()
      local lspconfig = require("lspconfig")
      local capabilities = require("cmp_nvim_lsp").default_capabilities()
 
      -- Keybindings applied when LSP attaches to buffer
      local on_attach = function(_, bufnr)
        local nmap = function(keys, func, desc)
          vim.keymap.set("n", keys, func, { buffer = bufnr, desc = "LSP: " .. desc })
        end
 
        nmap("gd", vim.lsp.buf.definition, "Goto Definition")
        nmap("gr", vim.lsp.buf.references, "Goto References")
        nmap("gi", vim.lsp.buf.implementation, "Goto Implementation")
        nmap("K", vim.lsp.buf.hover, "Hover Documentation")
        nmap("<leader>rn", vim.lsp.buf.rename, "Rename Symbol")
        nmap("<leader>ca", vim.lsp.buf.code_action, "Code Action")
        nmap("<leader>d", vim.diagnostic.open_float, "Show Diagnostic")
        nmap("[d", vim.diagnostic.goto_prev, "Previous Diagnostic")
        nmap("]d", vim.diagnostic.goto_next, "Next Diagnostic")
      end
 
      require("mason").setup()
      require("mason-lspconfig").setup({
        ensure_installed = {
          "lua_ls",
          "pyright",
          "ts_ls",
          "gopls",
          "rust_analyzer",
        },
        handlers = {
          function(server_name)
            lspconfig[server_name].setup({
              capabilities = capabilities,
              on_attach = on_attach,
            })
          end,
          ["lua_ls"] = function()
            lspconfig.lua_ls.setup({
              capabilities = capabilities,
              on_attach = on_attach,
              settings = {
                Lua = {
                  diagnostics = {
                    globals = { "vim" },
                  },
                },
              },
            })
          end,
        },
      })
    end,
  },
 
  -- Autocompletion Engine
  {
    "hrsh7th/nvim-cmp",
    event = "InsertEnter",
    dependencies = {
      "hrsh7th/cmp-nvim-lsp",
      "hrsh7th/cmp-buffer",
      "hrsh7th/cmp-path",
      "L3MON4D3/LuaSnip",
      "saadparwaiz1/cmp_luasnip",
    },
    config = function()
      local cmp = require("cmp")
      local luasnip = require("luasnip")
 
      cmp.setup({
        snippet = {
          expand = function(args)
            luasnip.lsp_expand(args.body)
          end,
        },
        mapping = cmp.mapping.preset.insert({
          ["<C-b>"] = cmp.mapping.scroll_docs(-4),
          ["<C-f>"] = cmp.mapping.scroll_docs(4),
          ["<C-Space>"] = cmp.mapping.complete(),
          ["<CR>"] = cmp.mapping.confirm({ select = true }),
          ["<Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_next_item()
            elseif luasnip.expand_or_jumpable() then
              luasnip.expand_or_jump()
            else
              fallback()
            end
          end, { "i", "s" }),
          ["<S-Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_prev_item()
            elseif luasnip.jumpable(-1) then
              luasnip.jump(-1)
            else
              fallback()
            end
          end, { "i", "s" }),
        }),
        sources = cmp.config.sources({
          { name = "nvim_lsp" },
          { name = "luasnip" },
          { name = "buffer" },
          { name = "path" },
        }),
      })
    end,
  },
}

Step 6: Pick a colorscheme

Install Tokyo Night for a high-contrast terminal interface:

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

Verifying startup performance

Profile your startup time using Neovim's built-in profiler flag:

bash
nvim --startuptime /tmp/nvim_startup.log
tail -n 10 /tmp/nvim_startup.log

With lazy-loading across syntax, LSP, and search, cold start stays under 30 milliseconds. Everything except the colorscheme is deferred until a buffer, a keymap, or an insert-mode event asks for it.

When this is the wrong choice

  • You edit files on machines you don't control. This config assumes git, make, ripgrep, and fd are present and that Mason can reach the network to download server binaries. On a locked-down jump host, none of that holds, and stock vim with no plugins is the thing that always starts.
  • You already have a config you know by heart. The payoff here is startup time and a file layout you can bisect. Neither is worth trading away the muscle memory of a setup that already works.
  • Your language has no Tree-sitter grammar and no language server. Steps 3 and 5 are most of the value. Without them you have installed a plugin manager, a fuzzy finder, and a colorscheme.