Build your first Neovim configuration in lua
2022-07-07 | 20 min read | Leer en españolLast updated: 2025-10-09
Neovim is a tool both powerful and extensible. With some effort it can do more than just modify text in a file. Today I hope I can teach you enough about Neovim's lua api to be able to build your own configuration.
We will create a configuration file called init.lua, add a couple of plugins and I'll tell you how to make your own commands.
This tutorial is meant for people totally new to Neovim. If you already have a configuration written in vimscript and want to migrate to lua, you might find this other article more useful: Everything you need to know to configure neovim using lua.
Some advice
Before we start, I suggest you install the latest stable version of Neovim. You can go to the release page in the github repository and download it from there. From now on I will assume you are using version 0.9.5 or greater.
If you don't feel comfortable using Neovim as an editor, follow the tutorial that comes bundled with it. You can start it using this command in the terminal.
nvim +Tutor
I will assume you know all the features Tutor teaches.
The entry point
First things first, we need to create a configuration file, the famous init.lua. And where that might be? Well, it depends on your operating system and also your environment variables. I can tell you a way of creating it using Neovim, that way we don't have to worry about those details.
Fun fact: Some articles online call the configuration file
vimrc. That is the name it has in Vim.
For this task we won't be using lua, we'll use the language created specifically for Vim: vimscript.
Let's open Neovim and execute this command.
:call mkdir(stdpath("config"), "p")
It'll create the folder where the configuration file needs to be. If you want to know what folder it created use this.
:echo stdpath("config")
Now we are going to edit the configuration file.
:exe "edit" stdpath("config") . "/init.lua"
After doing that we'll be in a "blank page". At this point the file doesn't exists on the system just yet. We need to save it with this command.
:write
Once the file actually exists we can edit it anytime we want using this.
:edit $MYVIMRC
If you are the kind of person that likes to automate things in scripts you'll be happy to know you can do all of it with one command.
nvim --headless -c 'call mkdir(stdpath("config"), "p") | exe "edit" stdpath("config") . "/init.lua" | write | quit'
Editor settings
To access the editor's setting we need to use the global variable vim. Okay, more than a variable this thing is a module, you'll find all sorts of utilities in there. Right now we are going to focus on the o property, with it we can modify all 350+ options Neovim has.
This is the syntax you should follow.
vim.o.option_name = value
Where option_name can be anything in this list. And value must be whatever that option expects.
You can see the list in Neovim using
:help option-list.
One thing you should know is that every option has a scope. Some options are global, some only apply in the current window or file. The scope of every option is mentioned in their help page. To navigate to the help page of an option follow this pattern.
:help 'option_name'
Useful options
number
This option expects a boolean value. This means it can only have two possible values: true or false. If we assign true we enable it, false does the opposite.
When we enable number Neovim starts showing the line number in the gutter.
vim.o.number = true
ignorecase
With this we can tell Neovim to ignore uppercase letters when executing a search. For example, if we search the word two the results can contain any variations like Two, tWo or two.
vim.o.ignorecase = true
smartcase
Makes our search ignore uppercase letters unless the search term has an uppercase letter. Most of the time this is used in combination with ignorecase.
vim.o.smartcase = true
hlsearch
Highlights the results of the previous search. It can get annoying really fast, this is how we disable it.
vim.o.hlsearch = false
wrap
Makes the text of long lines always visible. Long lines are those that exceed the width of the screen. The default value is true.
vim.o.wrap = true
breakindent
Preserve the indentation of a virtual line. These "virtual lines" are the ones only visible when wrap is enabled.
vim.o.breakindent = true
tabstop
The amount of space on screen a Tab character can occupy. The default value is 8. I think 2 is fine.
vim.o.tabstop = 2
shiftwidth
Amount of characters Neovim will use to indent a line. This option influences the keybindings << and >>. The default value is 8. Most of the time we want to set this with same value as tabstop.
vim.o.shiftwidth = 2
expandtab
Controls whether or not Neovim should transform a Tab character to spaces. The default value is false.
vim.o.expandtab = false
There are a few other things in the vim module we can use to modify variables, but we have other things to do right now. I talk about this topic in more detail here: Configuring Neovim - Editor Settings.
Keybindings
Because Neovim clearly doesn't have enough, we need to create more. To do it we need to learn about vim.keymap.set. Here is a basic usage example.
vim.keymap.set('n', '<space>w', '<cmd>write<cr>', {desc = 'Save'})
After executing this the sequence Space + w will call the write command.
Now let me explain vim.keymap.set parameters.
vim.keymap.set({mode}, {lhs}, {rhs}, {opts})
{mode}the mode where the keybinding can be executed. It can be a list of modes. We need to specify the mode's short name. Here are some of the most common.n: Normal mode.i: Insert mode.x: Visual mode.s: Selection mode.v: Visual + Selection.t: Terminal mode.o: Operator-pending.'': Yes, an empty string. Is the equivalent ofn+v+o.
{lhs}is the key we want to bind.{rhs}is the action we want to execute. It can be a string with a command or an expression. You can also provide a lua function.{opts}this must be a lua table. If you don't know what is, just think of it as a way to store several values in one place. It can have these properties.desc: A string that describes what the keybinding does. You can write anything you want.remap: A boolean that determines if our keybinding can be recursive. The default value isfalse. Recursive keybindings can cause some conflicts if used incorrectly. Don't enable it unless you know what you're doing. I will explain this recursive thing later.buffer: It can be a boolean or a number. If we assign the booleantrueit means the keybinding will only be effective in the current file. If we assign a number, it needs to be the "id" of an open buffer.silent: A boolean. Determines whether or not the keybindings can show a message. The default value isfalse.expr: A boolean. When enabled we can use vimscript or lua to calculate the value of{rhs}. The default value isfalse.
The leader key
When creating keybindings we can use the special sequence <leader> in the {lhs} parameter, it'll take the value of the global variable mapleader.
So mapleader is a global variable in vimscript that can be string. For example.
vim.g.mapleader = ','
After defining it we can use it as a prefix in our keybindings.
vim.keymap.set('n', '<leader>w', '<cmd>write<cr>')
This will make , + w save the current file.
What happens if we don't define it? The default value is \. I can recommend using the space key as leader. Like this.
vim.g.mapleader = ' '
Mappings
I'll show you just a few keybindings that you might find useful.
- Copy/paste from clipboard
The default behavior in Neovim (and Vim) doesn't take into account the system clipboard. It has its own mechanism to store text. When we copy something using the y keybinding that text goes to an internal register. I prefer to keep it that way, and what I do is create dedicated bindings to interact with the clipboard.
Copy to clipboard.
vim.keymap.set({'n', 'x'}, 'gy', '"+y')
Paste from clipboard.
vim.keymap.set({'n', 'x'}, 'gp', '"+p')
- Delete without changing the registers
When we delete text in normal mode or visual mode using c, d or x that text goes to a register. This affects the text we paste with the keybinding p. What I do is modify x and X to delete text without changing the internal registers.
vim.keymap.set({'n', 'x'}, 'x', '"_x')
vim.keymap.set({'n', 'x'}, 'X', '"_d')
The lower case x will delete one character in normal mode. In visual mode it will delete the current selection. Upper case X will just act like d.
- Select all text in current buffer
vim.keymap.set('n', '<leader>a', ':keepjumps normal! ggVG<cr>')
Plugin manager
We are going to use mini.nvim for this.
mini.nvim is actually a collection of lua modules. One of those modules has the plugin manager we are going to use, mini.deps. I recommend this because Neovim it's close to have its own plugin manager and at the surface level is almost identical to mini.deps. It'll take a few years for the built-in plugin manager to be available to everyone. Right now only those who are willing to compile Neovim from source or use a nightly build have access to it. But while we wait we can start using mini.deps.
Now, how does one install a plugin without a plugin manager?
That's been possible for quite a while now. Neovim (and Vim) can load a plugin if it's located in the correct directory. The trick is to know a valid path for a plugin and download it there. And that's exactly what we are going to do with some lua code.
local mini = {}
mini.branch = 'main'
mini.packpath = vim.fn.stdpath('data') .. '/site'
function mini.require_deps()
local uv = vim.uv or vim.loop
local mini_path = mini.packpath .. '/pack/deps/start/mini.nvim'
if not uv.fs_stat(mini_path) then
print('Installing mini.nvim....')
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/nvim-mini/mini.nvim',
string.format('--branch=%s', mini.branch),
mini_path
})
vim.cmd('packadd mini.nvim | helptags ALL')
end
local ok, deps = pcall(require, 'mini.deps')
if not ok then
return {}
end
return deps
end
Here we have a lua table called mini, in it we have a couple of options and a function.
Our function .require_deps() will download mini.nvim automatically if it's missing. Then it'll try to load the mini.deps module in safe way and return it back to us. If it fails it'll give us an empty lua table. Of course, this is just a function definition. In order for it to do anything we have to execute it.
local MiniDeps = mini.require_deps()
if not MiniDeps.setup then
return
end
If everything goes according to plan the variable MiniDeps will have all the functions of the mini.deps lua module. If it turns out that MiniDeps doesn't have a .setup property we know something went wrong and stop the execution. So no matter what happens Neovim will always be in a working state.
All the features in mini.nvim are opt-in, meaning we have to enable the modules we want to use. So after we load the module with require we need to execute a function called .setup().
MiniDeps.setup({
path = {
package = mini.packpath,
},
})
The .setup() function is where we place our custom configuration, if we need one. In this case is not strictly necessary. The variable mini.packpath already uses the default value mini.deps is expecting. But if we were to change the value of mini.packpath then mini.deps should be aware of that.
Let's download another plugin, a color scheme to make Neovim look better. Now we get to use the .add() function of mini.deps.
MiniDeps.add('folke/tokyonight.nvim')
This is the minimum amount of data mini.deps needs to download a plugin from github. Which is just the name of the user in github and the name of the repository. And funny enough this works like our custom function .require_deps(). It'll install the plugin automatically if it's missing and then it will add it to Neovim's runtimepath.
mini.deps also has this idea of a "plugin specification" which is a way for us to add more information about the plugin we want to download. For example, we can change the default branch we want to use. It looks a little bit like this.
MiniDeps.add({
source = 'nvim-mini/mini.nvim',
checkout = mini.branch,
})
Here instead of just providing a piece of text we use a lua table. The source property is mandatory, this should be the URL of the plugin. But since github is so popular mini.deps allows us to just specify the shorthand. In the checkout property we can provide a branch, a commit or tag. You can find more details about the plugin specification in the documentation.
We already downloaded mini.nvim in a path where mini.deps can track it. So having this MiniDeps.add() call is optional. Unless of course we want to deviate from the defaults, like changing the branch.
Now let's add the code to apply the new color scheme. After the call to MiniDeps.add() we write this.
vim.o.termguicolors = true
vim.cmd.colorscheme('tokyonight')
Here we enable termguicolors so Neovim can show the "best" version of the color scheme. Each color scheme can have two versions: one that works for terminals which only support 256 colors and another that specifies colors in hexadecimal notation (has way more colors).
We tell Neovim which theme we want using the colorscheme command. And yes, it looks like a lua function (it is). But under the hood is executing this vim command.
colorscheme tokyonight
We save these changes and restart Neovim, to trigger the download of mini.nvim. When Neovim starts it should show a message telling us is installing mini.nvim. After it's done mini.deps will make sure the rest of our plugins are installed and loaded.
Plugin configuration
Each plugin author has the freedom to create the configuration method they want. But then how do we know what to do? We have to rely on the documentation the plugin provides, we have no other choice.
Most plugins have a file called README.md in their repository, github is kind enough to render that file in the main page. It's the first place you should look for configuration instructions.
If for some reason the README doesn't have the information we are after, look for a folder called doc. Inside there should be a txt file, this is the help page. We can read it using Neovim executing the command :help name-of-file.
Conventions of lua plugins
Lucky for us a huge amount of plugins written in lua follow a certain pattern. They use a function called .setup(), and that function expects a lua table with some options. If there is something you should learn about the syntax of lua is how to create tables.
Our very own tokyonight.nvim follows this setup convention. So let's use it as an example.
In tokyonight's repository we can find a doc folder and inside there is a file called tokyonight.nvim.txt. We can read it in Neovim using this.
:help tokyonight.nvim.txt
This documentation shows we can customize the color scheme using a .setup() function. So let's say we want to disable the italics it has enabled by default. We would do something like this:
-- See :help tokyonight.nvim-tokyo-night-configuration
require('tokyonight').setup({
styles = {
comments = {italic = false},
keywords = {italic = false},
},
})
For this to be effective we need to add it before calling the colorscheme command.
How did I know that was going to work? First, knowing how lua tables work is a huge part of it. Second, the documentation shows all the default settings in the section tokyonight.nvim-tokyo-night-configuration. And the last thing to know is that most lua plugins will do their best to merge the default settings with our custom settings. This means we only have to specify the things we want to change.
We can save the changes and reload the config using the command :source $MYVIMRC.
Is worth mention that calling the .setup() function in tokyonight is optional. Is not like mini.deps where .setup() actually enables the plugin. This highlights the fact that lua plugins can do whatever they want. There isn't a fixed behavior or rules attached to this convention.
Vimscript plugins
There are a lot of useful plugins written in vimscript. Most of them we can configure modifying global variables. In lua we change global variables of vimscript using vim.g.
Did you know Neovim has a file explorer? Yeah, it's a plugin that comes bundled in Neovim. We can use it with the command :Lexplore. It is written in vimscript, so there is no .setup() function. To know how to configure it we need to check the documentation.
:help netrw
If you check the table of content in the help page, you'll notice a section called netrw-browser-settings. Once there we get a list of variables and their descriptions. Let's focus on the ones that start with g:.
For example, if we want to hide the help text in the banner we use netrw_banner.
vim.g.netrw_banner = 0
Another thing we can do is change the size of the window. For that we need netrw_winsize.
vim.g.netrw_winsize = 30
That's it... well no, there are more variables, but like this is the basic stuff you need to know. You check the docs, see the thing you want to change and use vim.g.
Bonus content
Recursive mappings
If you are familiar with the word "recursive" you might be able to guess what kind of consequences they can have. If not, let me try to explain with an example.
Let's say we have a keybinding that opens the file explorer.
vim.keymap.set('n', '<F2>', '<cmd>Lexplore<cr>')
Now let's add a recursive keybinding that uses F2.
vim.keymap.set('n', '<space><space>', '<F2>', {remap = true})
If we press Space twice the file explorer will show up. But if we change remap to false then nothing happens.
With recursive mappings we can use previous keybindings in the {rhs} argument. Those keybindings could be created by us or by other plugins. Non recursive mappings only give us access to keybindings defined by Neovim.
Probably the only time when you want a recursive mapping is when you want to use a feature defined by a plugin.
Why is it that recursive mappings can cause conflicts? Consider this.
vim.keymap.set('n', '*', '*zz')
Notice here we are using * in {lhs} and also {rhs}. If we make this recursive we create an endless cycle. Neovim will try to call * and never executes zz.
User commands
Yes, we can create our own commands. In lua we use this function.
vim.api.nvim_create_user_command({name}, {command}, {opts})
{name}must be a string. It has to start with an uppercase letter.{command}if it's a string it must be valid vimscript. Or it can be a lua function.{opts}must be a lua table. Is not optional. If you don't use any options you provide an empty table.
So we could create a function that "reloads" our configuration.
vim.api.nvim_create_user_command('ReloadConfig', 'source $MYVIMRC', {})
User commands are a fairly advance topic so if you want to know more details you can check the documentation.
:help nvim_create_user_command()
:help user-commands
Autocommands
With autocommands we can execute actions when Neovim triggers an event. You can check the complete list of events with this command :help events.
We can create autocommands with this function.
vim.api.nvim_create_autocmd({event}, {opts})
{event}must be a string with the name of an event.{opts}must be a lua table, its properties will determine the behavior of the autocommand. These are some of the most useful options.desca string that describes what the autocommand does.groupit can be a number or a string. If you provide a string it must be the name of an existing group. If you provide a number it must be the "id" of a group.patterncan be a lua table or a string. This allows us to control when we want to trigger the autocommand. Its value depends on the event. Check the documentation of the event to know the possible values.onceit can be a boolean. If enabled the autocommand will only execute once. The default value isfalse.commanda string. Must be valid vimscript. Is the action we want to execute.callbackit can be a string or a lua function. If you provide a string it must be the name of a function written in vimscript. This is the action we want to execute. It can't be used withcommand.
Here is an example. I'll create a group called user_cmds and add two autocommands to it.
local augroup = vim.api.nvim_create_augroup('user_cmds', {clear = true})
vim.api.nvim_create_autocmd('FileType', {
pattern = {'help', 'man'},
group = augroup,
desc = 'Use q to close the window',
command = 'nnoremap <buffer> q <cmd>quit<cr>'
})
vim.api.nvim_create_autocmd('TextYankPost', {
group = augroup,
desc = 'Highlight on yank',
callback = function(event)
vim.highlight.on_yank({higroup = 'Visual', timeout = 200})
end
})
Creating the group is optional by the way.
The first autocommand will make a keymap q to close the current window, but only if the filetype is help or man. In this example I'm using vimscript but I could have done it with a lua function.
The second autocommand will highlight the text we copy using y. If you want to test the effect try copying a line using yy.
To know more about autocommands in general check the documentation.
:help autocmd-intro
User modules
We can use lua modules to split our configuration into smaller pieces.
A common convention is to put every module we create into a single folder. We do this to avoid any potential conflict with a plugin. Lots of people call this module user (you can use another name). To make this module we need to create a couple of folders inside our config folder. First create a lua folder, and inside that create a user folder. You can do it with this command.
:call mkdir(stdpath("config") . "/lua/user", "p")
Inside /lua/user we create our lua scripts.
Let's pretend we have one called settings.lua. Neovim doesn't know it exists, it won't be executed automatically. We need to call it from init.lua.
require('user.settings')
If you want to know more details about require's behavior inside Neovim checkout.
:help lua-require
The require function
There is something you should know about require, it only executes code once. What does mean?
Consider this code.
require('user.settings')
require('user.settings')
In here the script settings.lua will only be executed once. If you want to create plugins or create a feature that depends on a plugin, this behavior is good. The bad news is if want to use :source $MYVIMRC to reload our config the results might not be what we expect.
There is a very simple hack we can do to make it source friendly. We can empty require's cache before using it. Like this.
local load = function(mod)
package.loaded[mod] = nil
require(mod)
end
load('user.settings')
load('user.keymaps')
If we do this in init.lua the source command will be able to execute all the files in our config.
WARNING. Be careful with this. Some plugins might act weird if you configure them twice. What do I mean? If we use source and call the .setup() function of a plugin a second time it might have unexpected effects.
init.lua
If we apply (almost) everything we learned here in a single configuration file this would be the result.
What's next?
Create a development environment that feels comfortable. Find out what plugins the Neovim community likes to use.
I know is difficult to start from scratch so here is a "starter template" you can check, or even use as your own base configuration.
- nvim-light: init.lua | github link
Another great option is kickstart.nvim. It's a good resource to learn about popular plugins and how to configure them.
Conclusion
Now we know how to configure some basic options in Neovim. We learned how to create our very own keybindings. We know how to get plugins from github. We manage to configure a couple of plugins, a lua plugin and one written in vimscript. We took a brief look at some advance topics like recursive mappings, user commands, autocommands and lua modules.
I'd say you have everything you need to start exploring plugins, read other people's config and learn from them.
Have any question? Feel free to leave a comment in one of these platform where I have shared this:
You can reach out to me on social media:
- Twitter @VonHeikemen_
- Bluesky @vonheikemen.bsky.social
- Mastodon @vonheikemen@hachyderm.io
Thank you for reading. If you find this article useful and want to support my efforts, buy me a coffee ☕