My Neovim Setup: From Zero to Productive
Table of Contents
My Neovim Setup: From Zero to Productive
I recently decided to build my own Neovim configuration from scratch instead of using a pre-built distribution like LazyVim or NVChad. Here’s what I learned and how you can set up a similar environment for JavaScript/TypeScript development.
Why Build Your Own Config?
Pre-built configurations are great, but building your own teaches you: - How Neovim actually works under the hood
- How to debug issues when plugins break
- Exactly what each keybinding does and why
Plus, you end up with a minimal setup that only includes what you actually need.
The Plugins
My setup uses Packer as the plugin manager. Here’s what I’m running:
| Plugin | Purpose |
|---|---|
| onedark.nvim | Beautiful dark theme |
| lualine.nvim | Status line |
| nvim-tree.lua | File explorer |
| telescope.nvim | Fuzzy finder (game changer!) |
| nvim-treesitter | Better syntax highlighting |
| nvim-lspconfig | Language server support |
| nvim-cmp | Autocompletion |
| kulala.nvim | HTTP client for API testing |
Key Settings
vim.opt.clipboard = 'unnamedplus' -- System clipboard integration
vim.opt.number = true -- Line numbers
vim.opt.relativenumber = true -- Relative line numbers
vim.opt.cursorline = true -- Highlight current line
vim.opt.expandtab = true -- Spaces instead of tabs
vim.opt.shiftwidth = 2 -- 2-space indentation
vim.opt.tabstop = 2
vim.g.mapleader = ' ' -- Space as leader key
The relative line numbers are crucial for Vim efficiency - they let you quickly jump to any visible line with commands like 5j or 12k.
The clipboard integration means everything you yank in Neovim goes straight to your system clipboard. Copy in Neovim, paste in your browser. No more "+y gymnastics.
My Keyboard Shortcuts
File Navigation
| Shortcut | Action |
|---|---|
Space + e |
Toggle file tree |
Space + f |
Focus file tree |
Space + ff |
Find files (fuzzy search) |
Space + fg |
Live grep (search text in project) |
Space + fb |
Find open buffers |
Telescope is the real productivity booster here. Instead of navigating through folders, I just hit Space + ff, type a few characters of the filename, and I’m there.
Tab Management
| Shortcut | Action |
|---|---|
Space + tn |
New tab |
Space + tc |
Close tab |
Tab |
Next tab |
Shift + Tab |
Previous tab |
Code Intelligence (LSP)
| Shortcut | Action |
|---|---|
gd |
Go to definition |
gr |
Find references |
K |
Hover documentation |
Space + rn |
Rename symbol |
Space + ca |
Code actions |
Autocompletion
| Shortcut | Action |
|---|---|
Ctrl + Space |
Trigger completion |
Tab / Shift + Tab |
Navigate suggestions |
Enter |
Confirm selection |
Ctrl + e |
Close menu |
HTTP Client (Kulala)
| Shortcut | Action |
|---|---|
Space + hr |
Run HTTP request |
Space + ha |
Run all requests |
Space + hn / Space + hp |
Navigate between requests |
Space + hy |
Copy as cURL |
Create a .http file like this:
### Get users
GET https://api.example.com/users
### Create user
POST https://api.example.com/users
Content-Type: application/json
{
"name": "John",
"email": "john@example.com"
}
Then just press Space + hr on any request to run it. No need to switch to Postman!
Clipboard Operations
| Shortcut | Action |
|---|---|
yy |
Copy line |
yw |
Copy word |
y (visual) |
Copy selection |
:%y |
Copy entire file |
dd |
Cut line |
p |
Paste |
Essential Vim Motions to Master
The real power of Vim comes from combining operators with motions. Here’s the formula:
operator + motion = action
Operators
d- deletec- change (delete + insert mode)y- yank (copy)>/<- indent
Text Objects
Text objects let you operate on semantic units of text:
| Command | What it does |
|---|---|
ciw |
Change inner word |
ci" |
Change inside quotes |
ci( |
Change inside parentheses |
di{ |
Delete inside braces |
da" |
Delete around quotes (including quotes) |
yap |
Yank a paragraph |
My Favorites
| Command | What it does |
|---|---|
ciw |
Change the word under cursor |
ci" |
Change text inside quotes |
dt) |
Delete until closing parenthesis |
f= |
Jump to next = sign |
. |
Repeat last change |
* |
Search for word under cursor |
:%s/old/new/g |
Replace all in file |
The Practice File
To really internalize these shortcuts, I created a comprehensive practice file. It has 12 sections covering:
- Horizontal Movement -
w,b,f,t,0,$ - Vertical Movement -
j,k,{,},gg,G - Text Objects -
ciw,ci",di(,da{ - Operators + Motions - Combining
d,c,ywith movements - Visual Mode -
v,V,Ctrl+v - Dot Command - Using
.to repeat actions - Search & Replace -
/,*,:%s - Macros -
q,@ - Registers - Named registers for multiple clipboards
- Marks - Bookmarking positions in files
- Window Splits - Managing multiple views
- Real World Challenges - Practical refactoring exercises
Here’s a sample exercise:
// TASK: Change "oldValue" to "newValue" (use f then ciw)
const result = calculate(oldValue, multiplier, offset)
// TASK: Delete everything inside parentheses (use di()
processData(argument1, argument2, argument3, somethingElse, moreStuff)
// TASK: Use Ctrl+v (block visual) to add "//" before each line
console.log("comment me 1")
console.log("comment me 2")
console.log("comment me 3")
The full practice file is available in my config repo. I recommend going through it section by section, trying to complete each task with the fewest keystrokes possible.
Tips for Learning
- Start with the basics - Master
hjkl,w,b,ebefore moving to complex motions - Learn one new thing per day - Don’t try to memorize everything at once
- Use the dot command -
.repeats your last change, making repetitive edits trivial - Think in text objects - “Change inside quotes” (
ci") is faster than selecting text manually - Practice on real code - The practice file helps, but real projects teach you faster
Conclusion
Building my own Neovim config took some time, but now I understand exactly what’s happening and can fix issues when they arise. The combination of Telescope for navigation, LSP for code intelligence, and mastering Vim motions has made me significantly faster at editing code.
The Full Configuration
Here’s my complete init.lua for reference:
-- Initialize packer (plugin manager)
vim.cmd [[packadd packer.nvim]]
require('packer').startup(function()
use 'wbthomason/packer.nvim' -- Packer manages itself
use 'nvim-tree/nvim-web-devicons' -- Icons for file tree and statusline
-- Aesthetic Theme
use {
'navarasu/onedark.nvim',
config = function()
require('onedark').setup {
style = 'darker' -- Options: 'dark', 'darker', 'cool', 'deep', 'warm', 'warmer', 'light'
}
require('onedark').load()
end
}
-- Statusline
use {
'nvim-lualine/lualine.nvim',
requires = { 'nvim-tree/nvim-web-devicons' },
config = function()
require('lualine').setup {
options = {
theme = 'onedark'
}
}
end
}
-- File Tree Viewer
use {
'nvim-tree/nvim-tree.lua',
requires = { 'nvim-tree/nvim-web-devicons' }, -- Optional icons
config = function()
require('nvim-tree').setup {
view = {
width = 30, -- Width of the tree viewer
side = 'left', -- Open on the left
},
filters = {
dotfiles = false, -- Show dotfiles (.env, .gitignore, etc.)
}
}
end
}
-- Telescope (fuzzy finder)
use {
'nvim-telescope/telescope.nvim',
tag = '0.1.8',
requires = { 'nvim-lua/plenary.nvim' },
}
-- Treesitter (better syntax highlighting)
use {
'nvim-treesitter/nvim-treesitter',
run = ':TSUpdate',
}
-- LSP Support
use 'neovim/nvim-lspconfig'
-- Autocompletion
use 'hrsh7th/nvim-cmp'
use 'hrsh7th/cmp-nvim-lsp'
use 'hrsh7th/cmp-buffer'
use 'hrsh7th/cmp-path'
use 'L3MON4D3/LuaSnip'
use 'saadparwaiz1/cmp_luasnip'
-- Kulala (HTTP client)
use {
'mistweaverco/kulala.nvim',
requires = { 'nvim-lua/plenary.nvim' },
ft = { 'http' },
config = function()
vim.cmd('silent! lua require("kulala").setup()')
local kulala = require('kulala')
vim.keymap.set('n', '<leader>hr', kulala.run, { buffer = true, desc = 'Run HTTP request' })
vim.keymap.set('n', '<leader>ha', kulala.run_all, { buffer = true, desc = 'Run all HTTP requests' })
vim.keymap.set('n', '<leader>hp', kulala.jump_prev, { buffer = true, desc = 'Previous HTTP request' })
vim.keymap.set('n', '<leader>hn', kulala.jump_next, { buffer = true, desc = 'Next HTTP request' })
vim.keymap.set('n', '<leader>hy', kulala.copy, { buffer = true, desc = 'Copy request as cURL' })
vim.keymap.set('n', '<leader>ht', kulala.toggle_view, { buffer = true, desc = 'Toggle headers/body' })
end,
}
end)
-- Basic Settings
vim.opt.clipboard = 'unnamedplus' -- Use system clipboard
vim.opt.number = true -- Show line numbers
vim.opt.relativenumber = true -- Relative line numbers
vim.opt.cursorline = true -- Highlight cursor line
vim.opt.termguicolors = true -- Enable true colors
vim.opt.wrap = false -- Disable line wrapping
vim.opt.expandtab = true -- Use spaces instead of tabs
vim.opt.shiftwidth = 2 -- Number of spaces for each indentation
vim.opt.tabstop = 2 -- Number of spaces for a tab
vim.opt.smartindent = true -- Enable smart indentation
-- Changing the mapleader
vim.g.mapleader = ' ' -- Set leader to spacebar
-- Toggle file tree with <leader>e
vim.api.nvim_set_keymap('n', '<leader>e', ':NvimTreeToggle<CR>', { noremap = true, silent = true })
-- Focus file tree with <leader>f
vim.keymap.set('n', '<leader>f', ':NvimTreeFocus<CR>', { desc = 'Focus file tree' })
-- Tab navigation (auto-open nvim-tree in new tabs)
vim.keymap.set('n', '<leader>tn', ':tabnew<CR>:NvimTreeOpen<CR>', { desc = 'New tab' })
vim.keymap.set('n', '<leader>tc', ':tabclose<CR>', { desc = 'Close tab' })
vim.keymap.set('n', '<Tab>', ':tabnext<CR>', { desc = 'Next tab' })
vim.keymap.set('n', '<S-Tab>', ':tabprevious<CR>', { desc = 'Previous tab' })
-- Telescope keymaps
local telescope_ok, telescope_builtin = pcall(require, 'telescope.builtin')
if telescope_ok then
vim.keymap.set('n', '<leader>ff', telescope_builtin.find_files, { desc = 'Find files' })
vim.keymap.set('n', '<leader>fg', telescope_builtin.live_grep, { desc = 'Live grep' })
vim.keymap.set('n', '<leader>fb', telescope_builtin.buffers, { desc = 'Find buffers' })
vim.keymap.set('n', '<leader>fh', telescope_builtin.help_tags, { desc = 'Help tags' })
end
-- Open nvim-tree on startup
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
require('nvim-tree.api').tree.open()
end
})
-- Enable filetype plugins
vim.cmd('filetype plugin on')
-- LSP Configuration (Neovim 0.11+ native API)
local ok_cmp_lsp, cmp_nvim_lsp = pcall(require, 'cmp_nvim_lsp')
-- JavaScript/TypeScript/JSX/React support
vim.lsp.config.ts_ls = {
cmd = { 'typescript-language-server', '--stdio' },
filetypes = { 'javascript', 'javascriptreact', 'javascript.jsx', 'typescript', 'typescriptreact', 'typescript.tsx' },
root_markers = { 'package.json', 'tsconfig.json', 'jsconfig.json', '.git' },
capabilities = ok_cmp_lsp and cmp_nvim_lsp.default_capabilities() or nil,
}
vim.lsp.enable('ts_ls')
-- LSP Keybindings (when LSP attaches to a buffer)
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local opts = { buffer = args.buf }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts) -- Go to definition
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts) -- Hover docs
vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts) -- Find references
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts) -- Rename symbol
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, opts) -- Code actions
end,
})
-- Autocompletion Setup (only load if plugins are installed)
local ok_cmp, cmp = pcall(require, 'cmp')
local ok_luasnip, luasnip = pcall(require, 'luasnip')
if ok_cmp and ok_luasnip then
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(), ['<C-e>'] = cmp.mapping.abort(),
['<CR>'] = cmp.mapping.confirm({ select = true }), -- Enter to confirm
['<Tab>'] = cmp.mapping(function(fallback) -- Tab to navigate
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' }, -- LSP completions
{ name = 'luasnip' }, -- Snippets
{ name = 'buffer' }, -- Words from current buffer
{ name = 'path' }, -- File paths
}),
}
end
-- Telescope Setup (only load if plugin is installed)
local ok_telescope, telescope = pcall(require, 'telescope')
if ok_telescope then
telescope.setup {
defaults = {
preview = {
treesitter = false,
},
}
}
end
-- Treesitter Setup (only load if plugin is installed)
local ok_ts, ts_configs = pcall(require, 'nvim-treesitter.configs')
if ok_ts then
ts_configs.setup {
ensure_installed = { 'javascript', 'typescript', 'tsx', 'html', 'css', 'json', 'lua' },
ignore_install = { 'kulala_http' },
auto_install = false,
highlight = { enable = true },
indent = { enable = true },
}
end
comments powered by Disqus