I recently rewrote my Neovim configuration in Lua in order to take advantage of a more intuitive programming language for configuration, Lua-only plugins, and powerful Neovim-only features such as:

  • The built in LSP: Language Server Protocol, IDE features like “show docs”, “find references”, “rename”, and “go to definition” for basically any language.
  • Treesitter: Powerful parsing of any programming language, which means plugins and syntax highlighting can make powerful and accurate transformations.

In this article I’d like to share some tips from my experience rewriting my config (tl;dr: check this commit to see what changed).

This article pales in comparison

Most of what I’ve learned I’ve learned from this GitHub repository, which is a lot more in-depth than this article.

Lua primer

Lua, at least the kind of Lua you’re likely to use for writing your Vim config, is really intuitive and easy to learn, assuming you already know an interpreted programming language like JavaScript. Here are the least intutive bits, the rest you can probably figure out when you first see it:

  • Single line comments are --. Multi line comments are --[[commented stuff in here]].
  • Variables are global by default. Use local myVar = 'foobar' whenever you declare a variable and you’re unlikely to run into trouble.
  • “Table” is the central type, which acts as “object”, “map”, and “array”, all in one:
    • Map: {foo = "bar", baz = "bat"}; like JSON, but = instead of :.
    • Array: local arr = {'foo', 'bar', 'baz',}; trailing commas are OK. Arrays are tables where the key is the index implicitly. Lua is 1-indexed. I.e. arr[2] == bar -- true
    • Object: local M = {} is typical when creating an object whose intent is to be used in other files. Add methods with local function M:myMethod(arg) print(arg) end to add a myMethod method to M.
  • Import modules (files) with local p = require('module.path'). The value returned from module/path.lua is stored in p. require caches the returned value, so future calls to require('module.path') will return the same value.
  • Parentheses when calling functions with only one argument are optional. This means you sometimes see weird code like require'somemodule'.method{foo='bar'}.
  • String concatenation uses the .. infix operator.

If you’d like a more accurate and less rambly introduction to Lua, check out this talk by TJ Devries (Neovim core contributor):

Store your configuration in Git

You will mess up. Store your vim config in Git upfront so you can increment on your migration process, and always return to a known state – or even go back to VimL if Lua isn’t for you.

You can find my (embarrassing) evolution of Vim usage on GitHub.

Start with structure before refactoring

Start the migration by putting files in their correct location without really rewriting anything. Unless you’ve set the $XDG_CONFIG_HOME variable to something other than ~/.config, you should make the following change:

- ~/.config/nvim/init.vim
+ ~/.config/nvim/init.lua

Then take the contents of your old .vimrc and wrap them inside

<contents of .vimrc goes here>

In Lua, [[ and ]] enclose a multi-line string, so you’ve probably guessed that the above code just evaluates the string (your old VimL config) as VimL. This will most likely work out of the box. Indeed, if you struggle to find the native Lua API for the change you want to do, you can call vim.cmd('<some snippet>') at any time. Commit now, and you’ll have a lot smaller and more obvious commits going forward.

Switch to a Lua-native package manager

I switched from (the excellent) vim-plug to packer, which lets me use Lua to define the dependencies, run Lua functions on updates or install.

Packer also supports bootstrapping itself, and provides a snippet in its README in order to do just that.


Don’t mess around with :PackerCompile or :PackerSync unless you really struggle with startup time. They can confuse matters really easily.

Rewrite bit by bit, then commit

I took the following approach to the migration:

  • Start by rewriting all my plugins to use packer, googling and fixing things as I went along.
  • Then I moved on to rewriting my ‘general’ settings, basically the settings that were set something=someVal in VimL.
  • Then I moved on to mappings.
  • Then I rewrote my after/ftplugin/{lang}.lua files
  • Then I added LSP client configuration.

Key points:

Interacting with the Neovim application will happen via the vim global variable.


  • vim.g.my_option = "my value" for setting global vim options.
  • vim.o.my_option = "my value" for setting other options.
  • vim.bo.my_option = "my value" for setting buffer-specific options.
  • vim.wo.my_option = "my value" for setting window-specific options.

If you’re not sure if an option is buffer or window just try setting it to one. It will usually tell you if it’s wrong if you try and run it.


vim.api.nvim_set_keymap(kind, lhs, rhs, options) is the API that Neovim exposes for defining custom key-mappings, but this is a bit long-winded, so I recommend creating a utility function:

local function map(kind, lhs, rhs, opts)
  vim.api.nvim_set_keymap(kind, lhs, rhs, opts)

Here kind can be:

  • '': The equivalent of (nore)map in VimL, i.e. the mapping applies in visual, normal, select, and operator-pending modes.
  • n: Normal mode
  • v: Visual mode
  • i: Insert mode,
  • etc.

The nore part of nnoremap, inoremap in VimL is configured as part of an option: noremap = true. I reckon most of your bindings will have { noremap = true, silent = true }, so you might as well assign this table to a variable pass it in to your mappings.

Here’s a snippet from my config where I’m taking advantage of the above:

local function map(kind, lhs, rhs, opts)
  vim.api.nvim_set_keymap(kind, lhs, rhs, opts)

local silentnoremap = {noremap = true, silent = true}

-- ...

-- Intuitive increment and decrement
map('n', '+', '<c-a>', silentnoremap)
map('n', '-', '<c-x>', silentnoremap)

-- Conveniently enter command mode
-- Don't use silent=true as this removes the command line.
map('n', ';', ':', {noremap=true})

-- Go to definition and pop back up
-- Don't do noremap to allow accessing LSP behaviour
map('n', '<right>', 'gd', { silent = true })
map('n', '<left>', '<c-t>', { silent = true })

Set up LSP

Setting up the native LSP client will largely depend on your own preferences, and the languages you write. That said, I recommend using the following (Packer) plugins:

use 'neovim/nvim-lspconfig' -- Easy LSP configuration
use 'kabouzeid/nvim-lspinstall' -- Install LSP servers on demand with :LSPInstall <name_of_language>

Then configure them according to their instructions. You’ll likely end up with other plugins that also want to integrate with LSP (e.g. Telescope), so your configuration will definitely look different to mine, but here it is regardless, with the hope that it can be useful as a reference, if nothing else.

Create your own namespace to avoid collisions

It is of course up to you if you want to keep your entire configuration in a single init.lua file, or if you want to spread your code across multiple files, but you have to be careful when organising files other than init.lua!

To avoid namespace collisions, don’t put your files directly under ~/.config/nvim/lua/foo.lua, but instead create another directory, named something that won’t collide with the dependencies/plugins that you use, such as using your GitHub username: ~/.config/nvim/lua/kinbiko/foo.lua. This way you can create files with configuration and mappings per plugin, should you so wish, without having worry about requireing the wrong thing.