Errors and fixes
Remix: changing the dev server port (and why PORT is not enough)
Vite ignores `PORT` by default. Change the port with `server.port` or `--port`; in production `remix-serve` reads `PORT`, but that is another process.
Key points
- In development the port belongs to Vite: `server.port` in `vite.config.ts` or `--port` on the command line.
- The `PORT` variable works with `remix-serve` in production, not with the Vite dev server.
- Behind a proxy you must also declare the HMR WebSocket port, or the page never updates.
Short answer: in development Vite decides the port, not PORT. Set server.port in vite.config.ts or pass --port. In production remix-serve reads PORT, but that is another process.
Two processes, two configurations
In development npm run dev starts the Remix plugin inside Vite. Vite creates the HTTP server, resolves modules and serves the HMR client; Remix supplies routing and rendering. The port therefore belongs to Vite, and PORT never enters the calculation, because no file in the plugin reads it. In production the command is remix-serve, a standalone HTTP server that reads PORT and has no relation to Vite. Two processes, two readers of the same variable.
A second layer matters here: Vite loads .env files into import.meta.env, not into process.env. If you write PORT=4000 in .env and read it with process.env.PORT inside the configuration, the value comes back empty and you fall back to the default. Reading that file for real needs loadEnv, or a variable exported by the shell before the command.
The configuration
// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix()],
server: {
port: 4000,
},
});
With this configuration npm run dev starts on 4000. The --port 4000 flag on the command line wins over the file, which helps when two projects must coexist. The order is fixed: flag first, then configuration, then the framework default.
Why PORT is not enough
Vite does not read PORT by default. Many deploy scripts assume the variable is respected, because other tools do respect it. If you want it to work anyway, read it in the configuration:
server: {
port: Number(process.env.PORT) || 3000,
},
In production the command differs: remix-serve ./build/server/index.js starts an HTTP server that reads PORT. Same variable, two behaviours, and that is where the confusion starts.
The diagnostic step
# terminal 1: start the dev server on the expected port
npx vite --port 4000 --strictPort
# terminal 2: see which process answers on that port
curl -sI http://localhost:4000 | head -n 1
If the reply is 200 and the Vite log shows the same port, the client is talking to the right process. If curl answers but the page never updates, the HTTP port is correct and the fault is the WebSocket: open the browser Network panel, filter by WS and compare the address with the one Vite prints at startup. If curl fails instead, the listening process is not the one you think: find the owner with netstat -ano | findstr :4000 on Windows or lsof -i :4000 on macOS.
The proxy case
server: {
port: 4000,
hmr: {
clientPort: 443,
protocol: "wss",
},
},
Behind nginx or a tunnel the page loads but never updates. The HMR client does not use the page URL: it builds its own address from server.hmr. When the page arrives on 443 and the socket starts on 4000, the connection dies quietly. The configuration above tells the browser where to find the WebSocket.
When the usual fix makes it worse
The most copied fix puts PORT into the configuration. In a container that variable is set by the runtime and holds 3000, so the dev server takes a port other services expect to be free, and the clash appears only on the machine of whoever exported the variable. The same holds for hmr.clientPort: 443, right behind a proxy and wrong locally, where the browser tries TLS against a plain port and the page freezes. A conditional fix, active only outside development, avoids both.
A named limitation
The documented limit of server.hmr.clientPort: it changes the port announced to the browser, not the socket path. If the proxy exposes the WebSocket on /hmr rather than the root, the client still asks for / and gets a 404 even with the right port. That case needs server.hmr.path with the same value in the proxy: the port alone is not enough and the symptom looks identical.
How to prevent it
- Document the port in the README and in the start script.
- Use
strictPortin development so a conflict is visible, not silent. - Do not assume
PORTbehaves the same in development and production. - Keep the WebSocket port next to the HTTP one, in the same configuration block.
Where to go next
In the catalogue: components, dashboard and blocks. The guides: the App Router structure and alternatives to MUI; the comparison: copy-paste or headless.
Frequently asked questions
Why doesn't PORT=4000 npm run dev change anything?
Because Vite does not read that variable on its own. You need server.port in the configuration or the --port flag.
How do I change the port in production?
With remix-serve, PORT=4000 npm start works because the process reads the variable. It is a different server from the development one.
The page loads but never updates. Why?
The HMR client tries the wrong port. Set server.hmr.clientPort or server.hmr.host.
Can each project use a different port?
Yes, but document it: a script that assumes 3000 fails silently on another project.
Do I need to configure the proxy too?
If Remix sits behind nginx or a tunnel, yes: the proxy must forward both HTTP and the Vite WebSocket.