All articles
ViteHMRlocal developmentlocalhost tunneling

Vite + Tunnel: HMR That Actually Works

Tunneling a Vite dev server works fine — except for two things that will trip you up the first time. The page loads but HMR doesn't. Or the page doesn't load at all because Vite refuses your tunnel hostname. Both are config one-liners once you know where to look.

Problem 1: "Blocked request. This host is not allowed."

Vite 5+ added host-check protection by default. If you open your tunnel URL and see Blocked request. This host is not allowed, that's the check refusing your *.portpreview.dev hostname.

Fix in vite.config.ts:

export default defineConfig({
  server: {
    host: true,                            // listen on 0.0.0.0
    allowedHosts: ['.portpreview.dev'],   // accept any subdomain
  },
});

The leading dot makes it a suffix match. You can put your specific tunnel hostname there if you prefer to be strict, but for daily dev the suffix is convenient — tunnel session URLs rotate.

Problem 2: HMR loads, then silently dies

HMR runs over WebSocket. When Vite serves at localhost:5173 but your browser loads from https://abc123.portpreview.dev, the HMR client tries to connect to the URL the server told it about — which is the local one. The tunnel doesn't forward that, so the WebSocket fails and HMR silently stops.

Fix the HMR config:

export default defineConfig({
  server: {
    host: true,
    allowedHosts: ['.portpreview.dev'],
    hmr: {
      clientPort: 443,            // browser connects on 443 (HTTPS)
      protocol: 'wss',            // secure WebSocket through the tunnel
    },
  },
});

Now the HMR client connects to wss://abc123.portpreview.dev (port 443), the tunnel forwards the upgrade, and edits propagate as expected. If you only need static reload and don't care about HMR over the tunnel, you can skip this — but you'll be using cmd-R a lot.

Why you'd want to tunnel Vite at all

Three real reasons:

  • Mobile testing. Open the URL on a phone, see the same build with HMR. Beats screen sharing or per-device emulators. See mobile testing with a tunnel for the full pattern.
  • Sharing in-progress work. Send a link to a designer or PM, they see your branch live. No deploy step.
  • OAuth and webhook flows that need HTTPS. Vite-served frontends that talk to Stripe redirect-only, Auth0, or similar. mkcert vs tunnel covers the choice.

SvelteKit, Astro, SolidStart all use Vite

The configs above apply to any framework that ships Vite as its dev server. SvelteKit's vite.config.js is the same shape. Astro has its own astro.config.mjs with a vite sub-object — put the same server options there. SolidStart, Nuxt 3 (via Vite), and Qwik City: same pattern.

Production builds are a different story

Everything above is dev-server config. When you run vite preview or serve a built bundle, none of this matters because there's no HMR and no host-check middleware. The tunnel is just forwarding static files at that point.

A few smaller things

  • Strict-host CORS. If your frontend hits an API on a different origin (different localhost port, or a separate API tunnel), set CORS on the API side. Vite doesn't proxy by default unless you tell it to via server.proxy.
  • WebSocket-heavy apps. Vite HMR is one WebSocket. If your app uses another WebSocket for game state, chat, or live data, that one is separate and needs to be configured in your client code to use the tunnel URL too.
  • Tunnel URL in env files. We put the active tunnel URL in .env.local as VITE_PUBLIC_URL when we need the frontend to know its own public address. import.meta.env.VITE_PUBLIC_URL reads it in the client.

Step-by-step

  1. Add the server.host, allowedHosts, and hmr block to your Vite config.
  2. Restart the dev server.
  3. Run npx portpreview 5173 (or whichever port Vite uses).
  4. Open the HTTPS URL in your browser or on a phone.
  5. Edit a component. Confirm HMR updates the page without a full reload.

If HMR isn't updating, open the browser console — Vite logs the WebSocket connection attempt and you'll see the URL it tried. That tells you whether the hmr.clientPort/protocol change took effect.

Join the PortPreview waitlist for tunnels that preserve WebSocket upgrades by default.

Frequently asked questions

Why does Vite block my tunnel URL with 'host is not allowed'?
Vite 5+ added a host-check that rejects unknown hostnames by default. Add allowedHosts: ['.portpreview.dev'] (or your tunnel domain) to the server config to accept tunnel requests.
How do I get Vite HMR to work through a tunnel?
Set hmr.clientPort: 443 and hmr.protocol: 'wss' in vite.config.ts. This tells the HMR client to connect over the tunnel's HTTPS/WSS instead of trying to reach localhost directly, which fails from outside your machine.
Does this work for SvelteKit, Astro, and Nuxt?
Yes. Any framework that uses Vite as its dev server takes the same server.host, allowedHosts, and hmr config. Astro nests it under vite in astro.config.mjs; the others put it in their normal Vite config file.