Your Vite app loads on your phone. Then every API call fails, and the page sits there with a spinner.

Nothing is wrong with your code. On the phone, localhost is the phone. Here's how to open a dev server from your Mac on your phone, and make the API calls work too.

The short answer

  1. Let Vite listen on your network: server.host: true in vite.config.js (or vite --host).
  2. Open the Network address Vite prints, on a phone on the same Wi-Fi.
  3. Proxy your API through Vite and call /api/... instead of http://localhost:3001/api/....

The rest of this post is why each step is needed, and what to do when one of them doesn't work.

Step 1: let the dev server listen on your network

Run npm run dev in a fresh Vite project and you get this:

 ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose 

Vite listens on localhost only, on purpose: nothing outside your Mac can reach a dev server by accident. To open it from your phone, tell it to listen on every network interface. You can pass --host each time, but putting it in the config means you never forget:

// vite.config.js export default { server: { host: true }, }; 

Start it again and Vite prints a second address:

 ➜ Local: http://localhost:5173/ ➜ Network: http://192.168.1.33:5173/ 

That 192.168.1.33 is your Mac's address on your Wi-Fi. Yours will differ. If you need it outside Vite, this prints it:

ipconfig getifaddr en0 

On some Macs Wi-Fi is en1, not en0; if the first prints nothing, try the second.

Type the Network address into Safari or Chrome on your phone. The phone has to be on the same Wi-Fi as the Mac; mobile data won't work, and neither will most guest networks or office networks that keep devices apart.

If the page doesn't load at all:

  • The macOS firewall may be blocking node. If it's on (System Settings → Network → Firewall), macOS asks whether to allow incoming connections the first time node listens on the network. Say Allow. If you clicked Deny once, find node in the firewall's Options list and change it.
  • A VPN on the Mac or the phone can route the request somewhere else. Turn it off for the test.
  • You typed localhost on the phone out of habit. It has to be the IP.

Step 2: why your API calls fail on the phone

This is the one that gets people second. The page loads, the styles load, hot reload even works, and then nothing comes back from the API.

Look at how the app calls it:

fetch("http://localhost:3001/api/projects") 

On your Mac, localhost:3001 is your API, so this works. On your phone, the same JavaScript runs in the phone's browser, and localhost means the phone itself. Nothing listens on port 3001 on your phone, so the request fails. Vite isn't involved; the browser goes straight to the address in the code.

There are a few ways around it. The cleanest one is to stop the browser from calling the API at all.

The fix: proxy the API through Vite

Vite has a built-in proxy. You tell it which paths belong to the API, and it forwards those requests to the API on your Mac:

// vite.config.js export default { server: { host: true, proxy: { "/api": "http://localhost:3001" }, }, }; 

Then change the app to call relative paths:

fetch("/api/projects") 

Now the phone only ever talks to Vite, at the Network address it already loaded the page from. Vite runs on your Mac, so when it forwards the request to localhost:3001, that's your Mac's localhost, where your API is.

Here's the result on an iPhone: the page comes from 192.168.1.33:5173, and the fetch to /api/health comes back 200 from the API on port 3001 on the Mac.

What this gets you:

  • The same code on the Mac and the phone. /api/... works on localhost:5173 and on the Network address, with no environment variable to switch.
  • No CORS. The browser sees one origin, Vite's, so there's no cross-origin request to allow.
  • The API stays private. It can keep listening on localhost only; just Vite has to listen on the network.

If your API's routes don't start with /api (say the backend serves /projects and the app calls /api/projects), strip the prefix on the way through:

proxy: { "/api": { target: "http://localhost:3001", rewrite: (path) => path.replace(/^\/api/, ""), }, }, 

If the backend is a NestJS app with app.setGlobalPrefix("api"), its routes already start with /api and the short version works as it is.

Other ways, and when they're better

Put the Mac's IP in the API URL. For example VITE_API_URL=http://192.168.1.33:3001 and fetch(import.meta.env.VITE_API_URL + "/projects"). It works, but the API now has to listen on the network too, it needs CORS for the phone's origin, and the IP changes when you switch networks or the router hands out a new one. Fine for a quick check; the proxy is less to keep in sync.

Use a tunnel. cloudflared tunnel --url http://localhost:5173 (Cloudflare) or ngrok http 5173 gives you a public HTTPS address. That's the one to use when the phone isn't on your Wi-Fi, when you want to send the link to someone else, or when you're testing anything a browser only allows over HTTPS: the camera, geolocation, service workers, Web Share. Keep the Vite proxy; the tunnel only needs to reach Vite. Vite may block the tunnel's hostname until you add it to server.allowedHosts.

A real device over USB. Safari's Web Inspector (Develop menu → your iPhone) and Chrome's remote debugging for Android let you see the phone's console and network tab from the Mac. Turn it on once and you'll see the failed localhost request straight away next time, instead of guessing.

Typing the IP gets old

You'll do this often enough that typing 192.168.1.33:5173 on a phone keyboard gets tedious. Two shortcuts:

  • In Chrome on the Mac, open the Network address, then the share menu (or right-click the page) → Create QR Code, and scan it with the phone's camera.
  • Bookmark the address on the phone. It stays right as long as your Mac keeps the same IP, which on a home network is usually true for a while.

I also build a menu bar app for local dev servers, Portlivo (full disclosure, it's mine). It marks the servers your phone can reach and has a Show QR Code for Phone item for each one, so I scan instead of typing. Servers that only listen on localhost don't get one, which is a handy hint that host: true is missing. It's at portlivo.com. Back to the setup.

A checklist for next time

  • server.host: true in vite.config.js.
  • Phone on the same Wi-Fi, opened at the Network address, not localhost.
  • Firewall allows node.
  • API calls are relative (/api/...) and server.proxy sends them to the backend.
  • Need HTTPS or a different network: a tunnel to Vite.

What's the first thing that broke the last time you tested a local app on your phone?

I'm Anmol. I build Mac apps and write about Mac productivity. Follow me on X at @anmolsethi_dev.