Start it, forget it, keep the same URL.
A development server manager for Neovim — static sites and your Next.js, Vite or Express app alike. Start and stop servers, watch their output, pin a port to a project so its URL never changes, and get sensible behaviour when Neovim is running over SSH.
2 running ~/code/shop ● Next.js running :3000 up 12m pinned http://127.0.0.1:3000/ ~/code/portfolio ● live-server running :5500 up 4m http://127.0.0.1:5500/index.html ○ browser-sync stopped :3001 <CR> open s start/stop r restart p port l logs ? help
Three papercuts, fixed
The usual live-reload workflow works right up until it doesn’t.
The URL kept moving
Ports are pinned per project. Bookmarks, an open devtools session, a CORS allowlist and a QR code taped to a test phone all keep working across restarts and across machines.
“Running” didn’t mean reachable
A browser opens only once the port actually accepts connections — not after a fixed one-second guess. A server that spawns but never answers is reported as such, not shown as healthy.
SSH quietly broke it
Editing on a remote box means the server binds loopback over there. The plugin
detects it, hands you the exact ssh -L command, and copies the URL
to your clipboard over OSC 52.
Install
Neovim 0.9+ and at least one backend. The plugin finds whichever you have.
{
"G00380316/live-server.nvim",
cmd = "LiveServer",
keys = {
{ "<leader>ls", "<Cmd>LiveServer toggle<CR>", desc = "Toggle live server" },
{ "<leader>lm", "<Cmd>LiveServer<CR>", desc = "Live server manager" },
{ "<leader>lo", "<Cmd>LiveServer open<CR>", desc = "Open in browser" },
},
opts = {},
}
Backends
| Backend | Install | Live reload | Notes |
|---|---|---|---|
node | nothing extra | Per framework | The project’s own dev server. Picked automatically inside a framework project. |
live_server | npm i -g live-server | Yes | Injects CSS without a page reload. |
browser_sync | npm i -g browser-sync | Yes | Syncs scroll, clicks and input across devices. |
serve | npm i -g serve or npx | No | Clean URLs, correct MIME types, production-like. |
python | already installed | No | Zero-install fallback. |
opts = {} is enough — every option has a working default, and the
plugin configures itself on first use if you never call setup() at
all. Run :checkhealth live_server to see what it found.
Commands
One command with subcommands and real Tab completion. A bare port or backend name is treated as a start request.
| Command | Does |
|---|---|
:LiveServer | Open the manager |
:LiveServer toggle | Start this project’s server, or stop it |
:LiveServer start 3000 | Start on a specific port |
:LiveServer start browser_sync dir=docs | Pick a backend and a directory |
:LiveServer restart | Restart on the same port |
:LiveServer open | Open the current file’s page |
:LiveServer logs | Tail the server’s output |
:LiveServer pin 4000 | This project always uses port 4000 |
:LiveServer expose | Let other devices reach it (asks first) |
:LiveServer reap | Stop servers left by a crashed session |
In the manager
| Key | Action | Key | Action |
|---|---|---|---|
| Enter o | Open in browser | d | Stop and remove |
| s | Start / stop | e | Toggle network exposure |
| r | Restart, same port | f | SSH forwarding command |
| p | Change port | X | Stop everything |
| P | Pin / unpin port | Tab | Next server |
| l | Show output | ? | Help |
Every one of these is also a :LiveServer
subcommand — nothing is keyboard-only.
Next.js, Vite, Express & friends
In a framework project, :LiveServer toggle runs the dev server the
repository already defines — rather than serving your source tree as files, which
would just render a page of raw JSX.
:LiveServer toggle # -> pnpm run dev --port 3000 --host 127.0.0.1
Detected, not configured
The framework comes from package.json dependencies — most specific
first, so SvelteKit is never mistaken for the Vite it is built on — and the package
manager from the lockfile.
The port arrives correctly
Next.js gets --hostname, others get --host, Vite gets
--strictPort so it fails instead of drifting. Express and CRA get
PORT in the environment, the convention they share.
It believes the process
Hard-coded app.listen(4000)? The plugin reads the address the server
announces, adopts that port and says so. A working server is never reported as
unreachable because of our bookkeeping.
It asks before it runs anything
Serving files reads files. Starting a dev server executes code the repository wrote, so the first time it shows you exactly what that is.
Run the Next.js dev server defined by this repository? script: dev = next dev via: pnpm Defined in ~/code/shop/package.json. This executes code from the repository.
The answer is remembered against that script, not the
whole file — a dependency bump won’t ask again, but changing what dev runs
will. Recognised frameworks also get a longer startup budget, because a cold Next.js
compile can take a minute before the port answers.
Ports that stay put
By default the port a project ends up with is remembered and reused. Availability is checked against the operating system, so a port held by a stray Docker publish or another editor is correctly seen as taken.
| Strategy | Behaviour |
|---|---|
"pin" | Reuse the project’s remembered port. Default. |
"scan" | First free port in port.range. |
"stable" | Derived from the project path — same checkout, same port on every machine, no shared state. |
"fixed" | Always the configured port; fail rather than move. |
Per-project configuration
Drop a .liveserverrc.json in the project root and everyone on the
team gets the same setup.
{
"server": "live_server",
"port": 4321,
"root": "public",
"open": "index.html",
"entry_file": "index.html"
}
Over SSH
When SSH_CONNECTION or SSH_TTY is set, the plugin stops
pretending a browser will open and prints what you can actually act on.
Serving portfolio at http://127.0.0.1:5500/ (on build-box) Forward it from your machine: ssh -N -L 5500:127.0.0.1:5500 you@build-box URL copied to your clipboard via OSC 52.
OSC 52 means the URL lands on your local clipboard even when the remote machine has no clipboard tool at all.
Security
A dev server serves your working tree, including files you have not committed. Two decisions follow from that.
Loopback by default
Servers bind 127.0.0.1. Exposing one to the network is possible
and sometimes necessary — testing on a phone — but it is always a deliberate,
confirmed act.
No accidental code execution
Nothing a repository defines — project-file command fields or
package.json dev scripts — runs without consent, recorded against the
exact command. Any edit revokes it and asks again, showing what it wants to run.
No shell, ever
Processes are spawned with argv lists, so a directory name can never become a command. Project files cannot serve anything outside their own project, and privileged ports are rejected.
Configuration
Unknown keys, wrong types and out-of-range values are reported in one message and
then ignored — a typo never breaks your init.lua.
require("live_server").setup({
server = "auto", -- or "live_server" | "browser_sync" | "serve" | "python"
host = "127.0.0.1",
expose = "ask", -- true | false | "ask"
port = {
strategy = "pin", -- "pin" | "scan" | "stable" | "fixed"
range = { 5500, 5599 },
},
browser = {
auto_open = true,
open_current_file = true, -- open the page for the buffer you are in
},
ui = {
icons = "auto", -- "auto" | "nerd" | "ascii" | "text"
spinner = true, -- false removes all motion
},
})
Accessibility
Status is always spelled out as a word next to the icon — nothing is conveyed by
colour or glyph alone. ui.icons = "text" removes glyphs entirely,
"ascii" avoids non-ASCII, and ui.spinner = false removes
all motion. Highlight groups are default links to standard groups, so
the panel follows your colorscheme’s contrast in light and dark. On a narrow
terminal the panel opens as a split rather than a cramped float, and lines never
exceed the window width.
Full reference: :help live-server. Everything from the previous
version still works — old commands and modules forward to the new API and warn
once. See :help live-server-migration.