$linuxjunkies
>

Neovim From Scratch with Lua

Build a modern Neovim config from scratch using init.lua, lazy.nvim, Treesitter, nvim-lspconfig, and Mason — step by step with no pre-made distribution.

IntermediateUbuntuDebianFedoraArch10 min readUpdated June 7, 2026

Before you start

  • Neovim 0.9 or later installed
  • git available in PATH
  • A C compiler (gcc or clang) for Treesitter parser compilation
  • Node.js and/or Python if installing LSP servers for those languages

Neovim's built-in Lua runtime replaced Vimscript as the preferred configuration language in Neovim 0.5 and has matured substantially since. This guide builds a working Neovim setup from an empty directory: structured init.lua, plugin management with lazy.nvim, syntax highlighting via Treesitter, and language intelligence through nvim-lspconfig and Mason. You will end up with something you understand and can extend, not a pre-packaged distribution.

Prerequisites and Installation

You need Neovim 0.9 or later. Most distro repositories lag behind, so check the version first.

nvim --version

Install or upgrade Neovim

Debian/Ubuntu — the official PPA ships current stable releases:

sudo add-apt-repository ppa:neovim-ppa/stable
sudo apt update
sudo apt install neovim

Fedora/RHEL family — Fedora ships a recent build; on RHEL/Rocky use the COPR:

# Fedora
sudo dnf install neovim

# Rocky Linux / RHEL 9
sudo dnf copr enable agriffis/neovim-nightly
sudo dnf install neovim

Arch:

sudo pacman -S neovim

You also need git, a C compiler (gcc or clang, required by Treesitter), and Node.js or Python if you intend to install LSP servers for those languages. Mason handles most server installation itself, but it shells out to the system npm/pip.

Step 1 — Create the Config Structure

Neovim reads ~/.config/nvim/init.lua as its entry point. A flat single file works but becomes unmanageable quickly. Use this layout instead:

mkdir -p ~/.config/nvim/lua/plugins
touch ~/.config/nvim/init.lua
touch ~/.config/nvim/lua/options.lua
touch ~/.config/nvim/lua/keymaps.lua

The lua/ directory is automatically on Neovim's Lua runtime path, so require("options") loads lua/options.lua. Subdirectories work the same way: require("plugins.treesitter") loads lua/plugins/treesitter.lua.

Populate init.lua

cat > ~/.config/nvim/init.lua <<'EOF'
require("options")
require("keymaps")
require("plugins")   -- lazy.nvim bootstrap lives here
EOF

Set core options

cat > ~/.config/nvim/lua/options.lua <<'EOF'
local opt = vim.opt

opt.number         = true
opt.relativenumber = true
opt.tabstop        = 4
opt.shiftwidth     = 4
opt.expandtab      = true
opt.smartindent    = true
opt.wrap           = false
opt.termguicolors  = true
opt.signcolumn     = "yes"
opt.updatetime     = 200
opt.undofile       = true
EOF

Step 2 — Bootstrap lazy.nvim

lazy.nvim is the current community standard plugin manager. It supports lazy-loading, lockfiles, and a clean UI. Create lua/plugins/init.lua — this file both installs lazy.nvim if absent and declares all plugins.

cat > ~/.config/nvim/lua/plugins/init.lua <<'EOF'
-- Bootstrap lazy.nvim
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({
  { import = "plugins.treesitter" },
  { import = "plugins.lsp" },
})
EOF

The import directive tells lazy to load plugin specs from separate files, keeping each concern isolated. Open Neovim now — lazy.nvim will clone itself on the first launch.

nvim

Step 3 — Treesitter for Syntax Highlighting

nvim-treesitter replaces regex-based syntax files with compiled parsers. The result is accurate, fast highlighting and enables features like structural text objects.

cat > ~/.config/nvim/lua/plugins/treesitter.lua <<'EOF'
return {
  {
    "nvim-treesitter/nvim-treesitter",
    build = ":TSUpdate",
    event = { "BufReadPost", "BufNewFile" },
    config = function()
      require("nvim-treesitter.configs").setup({
        ensure_installed = {
          "lua", "python", "javascript", "typescript",
          "bash", "json", "yaml", "markdown", "c",
        },
        highlight     = { enable = true },
        indent        = { enable = true },
        auto_install  = true,
      })
    end,
  },
}
EOF

The build = ":TSUpdate" field runs the parser compilation step automatically when the plugin is installed or updated. You need gcc or clang present for this to succeed. Parsers are compiled per-language and stored under ~/.local/share/nvim/.

Step 4 — LSP with nvim-lspconfig and Mason

Neovim ships a built-in LSP client; nvim-lspconfig provides ready-made configurations for hundreds of servers. Mason handles downloading and managing the server binaries themselves, so you are not relying on system packages for every language server.

cat > ~/.config/nvim/lua/plugins/lsp.lua <<'EOF'
return {
  -- Mason: LSP server installer
  {
    "williamboman/mason.nvim",
    cmd = "Mason",
    build = ":MasonUpdate",
    config = true,   -- calls require("mason").setup() with defaults
  },

  -- Bridge between Mason and lspconfig
  {
    "williamboman/mason-lspconfig.nvim",
    dependencies = { "williamboman/mason.nvim" },
    opts = {
      ensure_installed = { "lua_ls", "pyright", "ts_ls" },
      automatic_installation = true,
    },
  },

  -- Core LSP configuration
  {
    "neovim/nvim-lspconfig",
    dependencies = { "williamboman/mason-lspconfig.nvim" },
    config = function()
      local lspconfig = require("lspconfig")
      local capabilities = vim.lsp.protocol.make_client_capabilities()

      -- Per-server setup
      local servers = { "lua_ls", "pyright", "ts_ls" }
      for _, server in ipairs(servers) do
        lspconfig[server].setup({ capabilities = capabilities })
      end

      -- Keymaps applied whenever an LSP attaches to a buffer
      vim.api.nvim_create_autocmd("LspAttach", {
        callback = function(event)
          local map = function(keys, func)
            vim.keymap.set("n", keys, func, { buffer = event.buf })
          end
          map("gd",        vim.lsp.buf.definition)
          map("K",         vim.lsp.buf.hover)
          map("rn",vim.lsp.buf.rename)
          map("ca",vim.lsp.buf.code_action)
          map("[d",        vim.diagnostic.goto_prev)
          map("]d",        vim.diagnostic.goto_next)
        end,
      })
    end,
  },
}
EOF

The ensure_installed list in mason-lspconfig tells Mason which servers to download automatically. Add or remove server names as needed — run :Mason inside Neovim to browse available servers interactively.

Step 5 — Set a Leader Key

The <leader> key prefixes custom shortcuts. Set it before lazy loads plugins or keymaps that reference it.

cat > ~/.config/nvim/lua/keymaps.lua <<'EOF'
vim.g.mapleader      = " "   -- Space
vim.g.maplocalleader = " "

-- Quick config reload
vim.keymap.set("n", "sv", "source $MYVIMRC",
  { desc = "Source init.lua" })
EOF

Move the require("keymaps") line to the very top of init.lua so leader is defined before lazy starts loading plugins.

Step 6 — Verify Everything Works

Open Neovim and run the lazy UI:

nvim
# Then inside Neovim:
:Lazy

You should see all plugins listed as installed. Press U in the Lazy UI to update all at once. Then check the LSP:

# Open a Python file and inspect attached servers
:LspInfo

Output will show the active server for the current buffer and root directory detection. Run :Mason to confirm lua_ls, pyright, and ts_ls are installed (green checkmarks).

Test Treesitter with:

:TSInstallInfo

Parsers listed as installed will show a version and path. For any file type, :InspectTree (Neovim 0.9+) opens the parsed syntax tree — a quick proof that Treesitter is active.

Troubleshooting

Treesitter parsers fail to compile

Install a C compiler. On Debian/Ubuntu: sudo apt install build-essential. On Fedora: sudo dnf install gcc. On Arch: sudo pacman -S base-devel. Then run :TSUpdate.

LSP server not attaching

Check :LspInfo for the reason. Common causes: the server is not in ensure_installed, Mason has not finished installing it yet, or the file has no detectable root (no package.json, pyproject.toml, etc.). Open the file from the project root, not a random directory.

Lua type errors from lua_ls about Neovim globals

Add a .luarc.json at the project root or configure lua_ls with the Neovim runtime path:

lspconfig.lua_ls.setup({
  settings = {
    Lua = {
      runtime  = { version = "LuaJIT" },
      workspace = {
        library = vim.api.nvim_get_runtime_file("", true),
        checkThirdParty = false,
      },
    },
  },
})

lazy.nvim fails to clone

Confirm git is installed and that github.com is reachable. Behind a corporate proxy, export HTTPS_PROXY before launching Neovim.

tested on:Ubuntu 24.04Fedora 40Arch rollingDebian 12

Frequently asked questions

Do I need nvim-cmp for LSP completion to work?
No. Neovim's built-in completion (triggered with Ctrl-X Ctrl-O) surfaces LSP suggestions without any extra plugin. nvim-cmp adds a richer popup UI and multiple completion sources, but it is optional.
What is the difference between Mason and nvim-lspconfig?
nvim-lspconfig provides the configuration templates that tell Neovim how to launch and communicate with each language server. Mason handles downloading the actual server binaries. They solve different problems and are usually used together.
Can I use this config alongside an existing init.vim?
No, not directly. Neovim loads either init.vim or init.lua, not both. If you have an existing init.vim, migrate its settings to Lua or source it from init.lua using vim.cmd('source ~/.config/nvim/legacy.vim') as a temporary bridge.
Why does Treesitter highlighting sometimes look worse than the old regex highlighting?
The parser may not be installed for that file type, or the colorscheme lacks Treesitter highlight groups. Run :TSInstall <filetype> and switch to a Treesitter-aware theme such as tokyonight or catppuccin.
Is lazy.nvim's lockfile safe to commit to version control?
Yes, and it is recommended. The lazy-lock.json file records the exact commit hash of every plugin, letting you reproduce the same setup on another machine or roll back a bad plugin update with :Lazy restore.

Related guides