A tiny, zero-dependency local web server for Neovim — written in pure Lua with vim.loop.
Start a server on any file or folder, auto-reload the browser on save, and quickly reopen existing ports.
- Pure Lua: no npm, no Python, no binaries.
- Local-only: binds to
127.0.0.1(loopback). - SSE live-reload: instant page refresh on file changes (debounced).
- CSS hot-inject: stylesheet changes apply instantly without a full page reload.
- Directory listing: clean index when no
index.htmlexists. - Telescope UX: pick a path (file or directory) and a port from a friendly picker.
- Which-key friendly: group label in
init, real mappings inkeys, no conflicts. - Same-port retargeting: starting on the same port updates the served root/index (reuses the same browser tab/URL).
- Auto-start: optionally start a server when you open an HTML file.
- Statusline: show active servers in your statusline/lualine.
This plugin serves only on
127.0.0.1. It's meant for local dev previews, not production.
- Neovim 0.8+ (tested on 0.9 / 0.10).
- Linux, macOS, or Windows.
- telescope.nvim recommended for the best picking UX (falls back to
vim.ui.select/inputif missing). - which-key.nvim recommended.
-- lua/plugins/live-server.lua
return {
"selimacerbas/live-server.nvim",
dependencies = {
"folke/which-key.nvim",
"nvim-telescope/telescope.nvim", -- recommended for path picker
},
init = function()
-- which-key group label only (best practice)
local ok, wk = pcall(require, "which-key")
if ok then wk.add({ { "<leader>l", group = "LiveServer" } }) end
end,
opts = {
default_port = 8000,
live_reload = { enabled = true, inject_script = true, debounce = 120, css_inject = true },
directory_listing = { enabled = true, show_hidden = false },
},
-- map to user commands (robust lazy-loading)
keys = {
{ "<leader>ls", "<cmd>LiveServerStart<cr>", desc = "Start (pick path & port)" },
{ "<leader>lo", "<cmd>LiveServerOpen<cr>", desc = "Open existing port in browser" },
{ "<leader>lr", "<cmd>LiveServerReload<cr>", desc = "Force reload (pick port)" },
{ "<leader>lt", "<cmd>LiveServerToggleLive<cr>", desc = "Toggle live-reload (pick port)" },
{ "<leader>li", "<cmd>LiveServerStatus<cr>", desc = "Show server status" },
{ "<leader>lS", "<cmd>LiveServerStop<cr>", desc = "Stop one (pick port)" },
{ "<leader>lA", "<cmd>LiveServerStopAll<cr>", desc = "Stop all" },
},
config = function(_, opts)
require("live_server").setup(opts)
end,
}- Press
<leader>ls(or run:LiveServerStart). - Pick a path (file or directory), then pick a port (default
8000). - Your browser opens
http://127.0.0.1:<port>/.
If you pick a file, the server serves the file's folder with that file as the default index. If you pick the same port again later, the server retargets to the new root/index instead of creating a new instance.
| Command | Description |
|---|---|
:LiveServerStart |
Pick a path and port, start serving |
:LiveServerOpen |
Pick a port, open its URL in browser |
:LiveServerReload |
Force reload all connected clients |
:LiveServerToggleLive |
Enable/disable file watching for a port |
:LiveServerStatus |
Show running servers (port, root, uptime, clients) |
:LiveServerStop |
Pick a port to stop |
:LiveServerStopAll |
Stop all servers |
Configured via require("live_server").setup({...}) or opts = { ... } in your lazy spec.
{
default_port = 8000, -- default suggestion in the port picker
open_on_start = true, -- open browser after start/retarget
notify = true, -- use vim.notify for events
notify_on_reload = false, -- notify on every live-reload event
headers = { ["Cache-Control"] = "no-cache" }, -- extra response headers
cors = false, -- true/"*" or origin string (e.g. "http://localhost:3000")
index_names = { "index.html", "index.htm" }, -- index files to try in order
auto_start = nil, -- set to auto-start on filetype, e.g.:
-- auto_start = { filetypes = { "html" }, port = 8000 },
live_reload = {
enabled = true, -- watch files under the served root
inject_script = true, -- injects <script src="/__live/script.js">
debounce = 120, -- ms debounce for rapid changes
css_inject = true, -- hot-swap CSS without full page reload
},
directory_listing = {
enabled = true, -- render an index page if no index.html
show_hidden = false, -- include dotfiles in listing
},
}When css_inject is enabled (default), editing a .css file triggers an instant stylesheet swap in the browser — no full page reload, no DOM state lost. All other file changes still trigger a full reload.
Set auto_start to automatically start a server when you open a matching filetype:
auto_start = { filetypes = { "html" }, port = 8000 }The server starts once per directory — opening another HTML file in the same folder won't spawn a duplicate.
Create a .liveignore file in your served root to skip file-watcher noise. One pattern per line, * as wildcard, # for comments:
# Don't reload on these
node_modules
*.log
.git
dist
Enable cross-origin headers for all responses:
cors = true, -- Access-Control-Allow-Origin: *
cors = "http://localhost:3000", -- specific originUseful when your frontend (on the live server) makes API calls to a separate backend.
Show active servers in lualine or any statusline:
-- lualine example
sections = {
lualine_x = {
{ require("live_server").statusline },
},
}Returns "[LS :8000]" when a server is running, or "" when idle.
404 and 400 errors display a clean, dark-mode-aware HTML page instead of raw text — easier to spot during development.
All under the which-key group <leader>l:
| Key | Action |
|---|---|
<leader>ls |
Start (pick path & port) |
<leader>lo |
Open existing port in browser |
<leader>lr |
Force reload (pick port) |
<leader>lt |
Toggle live-reload (pick port) |
<leader>li |
Show server status |
<leader>lS |
Stop one (pick port) |
<leader>lA |
Stop all |
We register only the group label in
init, and return actual mappings inkeys— the recommended pattern for Folke's ecosystem to avoid conflicts and enable lazy-loading on keypress.
- Local by default: binds to
127.0.0.1. If you want LAN, you can change the bind address inserver.lua(not recommended for security). - Path safety: requests are realpath-checked to prevent escaping the served root.
- Index resolution: root directory →
default_index(if starting from a file) →index_namesin order → directory listing. Subdirectories always use their own index files. - Port 0 (OS-assigned): pass
port = 0to let the OS pick a free port. The actual port is available viainst.portafterserver.start(). - Same port, new path: reusing the same port retargets the server → same URL, so browsers typically reuse the same tab.
- Event injection:
GET /__live/inject?event=<type>&data=<json>lets external processes broadcast SSE events to connected clients. - Graceful exit: all servers are automatically stopped on
VimLeavePre.
-
"Port in use or failed to bind" Another process is using that port (or a previous server didn't exit cleanly). Pick a different port, or stop the other process. You can stop live-server instances via
:LiveServerStopor:LiveServerStopAll. -
"start() bad argument #2 to 'start' (table expected, got number)" Some
luvbuilds expectfs_event:start(path, {recursive=true}, cb)while others acceptstart(path, cb). The plugin tries both. Make sure you're on the latest plugin files. -
Browser didn't open We try
vim.ui.open(NVIM 0.10) and fall back toxdg-open/open/start. If none work, copy the URL from the message and open manually. -
Live-reload didn't trigger
- It only injects into HTML pages.
- Ensure the served root actually changed (the watcher is per root).
- Check
.liveignoreisn't excluding the file. - Try
:LiveServerToggleLiveoff/on, or:LiveServerReloadto force.
local ls = require("live_server")
ls.setup({ ... }) -- configure defaults
ls.start_picker() -- UI flow: pick path, then port
ls.open_existing() -- pick a port → open in browser
ls.force_reload() -- broadcast reload to clients
ls.toggle_livereload() -- enable/disable live-reload for a port
ls.status() -- print running server info
ls.statusline() -- returns "[LS :8000]" or ""
ls.stop_one() -- pick a port → stop
ls.stop_all() -- stop everythinglocal server = require("live_server.server")
local util = require("live_server.util")
local inst = server.start({
port = 0, -- port 0 = OS-assigned
root = "/path",
token = util.random_token(16), -- optional, see "Token auth" below
protected_paths = { "^/content%.md$" }, -- Lua patterns; require ?t=<token>
})
server.send_event(inst, "scroll", '{"line":42}') -- broadcast custom SSE event
server.reload(inst, "file.html") -- broadcast reload event
server.update_target(inst, new_root, new_index) -- retarget without restart
server.connected_client_count(inst) -- number of SSE clients
server.stop(inst) -- shut downExternal processes can inject SSE events via HTTP:
GET /__live/inject?event=<type>&data=<url-encoded-json>[&t=<token>]
This broadcasts the event to all connected SSE clients. Used by markdown-preview.nvim for cross-instance scroll sync. The t=<token> parameter is required when the server was started with cfg.token.
When cfg.token is set, the server requires ?t=<token> on:
/__live/events(the SSE stream)/__live/inject(event injection)- Any path matching one of the Lua patterns in
cfg.protected_paths
Static assets (index.html, style.css, etc.) are intentionally not gated because the browser bootstraps from them before any JS runs and cannot append query strings to tags it discovers itself. Token-bearing requests for everything else are the caller's responsibility: pass ?t=<token> to the browser via the initial URL, then stash it in sessionStorage and append it on every fetch/EventSource call.
util.random_token(byte_len) generates a hex token (default 16 bytes = 128 bits) from /dev/urandom, falling back to math.random seeded from uv.hrtime + os.time + pid. util.secure_compare(a, b) is a constant-time-ish string compare for token validation.
Token auth is opt-in. When cfg.token is nil (the default), no auth is applied and all endpoints behave as before.
- Optional LAN binding with allowlist.
- Pluggable middlewares (custom headers, rewrites).
- Directory listing customization (sorting, columns).
PRs and issues are welcome!
Please include your OS, Neovim version, and (if relevant) vim.loop/luv version when reporting bugs. Repro steps make fixes fast.
MIT © Selim Acerbaş