You run `npm run dev`. Terminal says: Local: http://localhost:3000. You open it. It works. Your friend opens the same URL on their phone. Nothing. You try your public IP with :3000. Still nothing. You install ngrok, paste a weird URL, and suddenly your half-broken sidebar is live on the internet. What just happened?
This isn't React being moody. It's networking — the stuff we happily ignore until a webhook, a client demo, or a "can you share your screen but actually your localhost" moment forces us to care.
start with the IP address
Every device on a network gets an address. IPv4 looks like four numbers separated by dots — 192.168.1.42. Routers use it to figure out where to send packets. Think apartment building: IP is the unit number, port is the specific door inside that unit.
what localhost actually is
`localhost` is a hostname your OS resolves to `127.0.0.1`. That's the loopback address — a special range reserved for "this device talking to itself." When your browser hits localhost:3000, the request never leaves your laptop. It goes out one virtual interface and comes right back in. No Wi‑Fi. No router. No drama.
$ ping localhost
PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.045 msNotice the response time — fractions of a millisecond. Because you're literally talking to yourself. Fast, reliable, and completely useless for anyone who isn't sitting at your keyboard.
npm run dev → :3000never leaves the boxlocalhost is a hostname that resolves to 127.0.0.1 — traffic loops back inside your machine. Fast, private, invisible to the internet.
127.0.0.1 vs 0.0.0.0 — not the same thing
This trips people up constantly. When Next.js or Vite says `Local: http://localhost:3000` and `Network: http://192.168.1.42:3000`, they're telling you two different binding stories.
private IP, public IP, and the NAT wall
Your home router has a public IP — the one websites see when you browse. Your laptop has a private IP like 192.168.x.x behind that router. NAT (Network Address Translation) lets many devices share one public IP for outbound traffic. Inbound is another story.
When your friend tries `YOUR_PUBLIC_IP:3000`, the packet hits your router. The router thinks: "port 3000? I don't know who asked for that." Drop. Unless you've set up port forwarding — which most people haven't and most ISPs make annoying.
enter ngrok
ngrok flips the problem. Instead of waiting for inbound connections (which NAT blocks), your machine opens an outbound connection to ngrok's servers. They give you a public URL — something like `https://abc123.ngrok-free.app` — and forward traffic through that tunnel to your local port.
$ ngrok http 3000
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000Your app still runs on localhost. ngrok is just a very polite middleman. Webhook from Stripe? Point it at the ngrok URL. QA in another country? Send the link. Test OAuth callback? Works — as long as the tunnel stays open.
when to use what
localhost — daily dev, fastest feedback, zero setup. LAN IP (192.168.x.x) — test on your phone/tablet on the same Wi‑Fi, no third party. ngrok, Cloudflare Tunnel, or localtunnel — webhooks, remote teammates, anything that needs a real public HTTPS URL hitting your machine. Actual deploy (Vercel, Railway, etc.) — when you're done pretending localhost is production.
quick mental model
IP = where on the network. Port = which app. localhost = loopback to yourself. Private IP = your house's internal rooms. Public IP = your building's street address. NAT = the doorman who doesn't let strangers wander in. ngrok = a friend at the door who forwards messages to your room.
tl;dr
localhost works because 127.0.0.1 never leaves your machine. Your friend can't use it because they're not on your machine. LAN IP works on the same network. The public internet can't reach your :3000 without port forwarding or a tunnel. ngrok builds that tunnel so the outside world can knock on localhost's door — temporarily, on purpose.