Skip to content
Blog

Deploy Vite React to Vercel and Express API to Render (Free)

By Published A short read

Deploy a Vite React app to Vercel and an Express API to Render: the browser loads the site from Vercel and calls the API on Render

To deploy a Vite React app to Vercel and an Express API to Render, put both in one GitHub repo, create a Render Web Service for the server folder and a Vercel project for the web folder, then connect them with two environment variables: VITE_API_URL tells React where the API lives, and CORS_ORIGIN tells Express which site may call it.

In this post we build a small working example and deploy both halves: VITE_API_URL goes on Vercel and CORS_ORIGIN on Render. Then we fix the three errors almost everyone hits: the CORS error, the 404 on refresh, and an API URL that comes out as undefined. Last, we add a MongoDB Atlas database and a one-service setup with no CORS at all.

Key takeaways

  • Vercel serves the built React app as static files. Render runs the Express server. The browser talks to both.
  • Vite only exposes variables that start with VITE_, and it bakes them into the JavaScript at build time. Change one on Vercel and you must redeploy.
  • The browser blocks the API's answer unless Express sends an Access-Control-Allow-Origin header that matches your Vercel address exactly. The cors package does this.
  • A React app with client-side routes needs a vercel.json rewrite to /index.html, or refreshing /about gives a 404.
  • As of October 2026, Render's free web services spin down after 15 minutes without traffic and take about a minute to wake up. Show a loading message so the first visitor doesn't think the site is broken.
  • Pinging a free Render service to keep it awake uses free instance hours: one service awake all month is 744 of your workspace's 750.

This post goes with the most-watched video on the letsBug channel, from September 2023. The video shows the Vercel and Render dashboards as they looked then. The written steps below are up to date: checked against Vercel's and Render's docs in October 2026, with Vite 8, React 19 and Express 5.

How the pieces fit together

When someone opens your site, two separate servers do the work:

  1. The browser asks Vercel for the page. Vercel sends back index.html and the JavaScript and CSS that vite build made. There's no Node.js server running your code here, just files.
  2. React starts in the browser and calls fetch() on your Render address, for example https://my-api.onrender.com/api/hello.
  3. Express on Render answers with JSON, plus a CORS header that says "the Vercel site is allowed to read this".
Architecture: the browser gets index.html and JavaScript from the Vercel static site, then calls fetch on the Express API on Render, which returns JSON with an Access-Control-Allow-Origin header
Two hosts, two addresses. The environment variables are how each side learns the other's address.

Because the page comes from my-app.vercel.app and the data comes from my-api.onrender.com, they're different origins. That's why CORS comes into it at all.

What we'll build

One repo, two folders. Each host only looks at its own folder:

my-app/
├── .gitignore
├── server/              (deployed to Render)
│   ├── index.js
│   └── package.json
└── web/                 (deployed to Vercel)
    ├── .env.development
    ├── vercel.json
    ├── index.html
    ├── package.json
    └── src/App.jsx

We tested every file in this post with Node.js 24, Express 5.3.0, cors 2.8.6, Vite 8.3.4, React 19.3.0 and @vitejs/plugin-react 6.1.2. Newer patch versions should work the same.

Step 1: the Express API

Make the server folder and add this package.json:

{
  "name": "server",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "engines": {
    "node": "24.x"
  },
  "dependencies": {
    "cors": "2.8.6",
    "express": "5.3.0"
  }
}

The engines field matters on Render: it's one of the ways Render picks the Node.js version, and Render's docs ask for a range with an upper bound, which 24.x is. Now server/index.js:

import express from 'express'
import cors from 'cors'

const app = express()

// Sites allowed to call this API, comma-separated, e.g. https://my-app.vercel.app
const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173')
  .split(',')
  .map((origin) => origin.trim())

app.use(cors({ origin: allowedOrigins }))

app.get('/api/hello', (req, res) => {
  res.json({ message: 'Hello from Express on Render!' })
})

const port = process.env.PORT || 3000
app.listen(port, () => {
  console.log(`API listening on port ${port}`)
})

Line by line:

  • allowedOrigins comes from the CORS_ORIGIN environment variable. On your laptop it isn't set, so it falls back to http://localhost:5173, the address of Vite's dev server.
  • cors({ origin: allowedOrigins }) checks each request's Origin header. If it's on the list, the response gets Access-Control-Allow-Origin set to that origin, plus Vary: Origin. If it isn't, the header is left out and the browser refuses to hand the response to your React code.
  • process.env.PORT: Render tells your app which port to listen on through PORT (its default is 10000). Locally we use 3000.

Run it:

cd server
npm install
npm start

It prints API listening on port 3000. Open http://localhost:3000/api/hello and you should see {"message":"Hello from Express on Render!"}.

Why not cors() with no options? That sends Access-Control-Allow-Origin: *, which lets every website read your API's responses. MDN's advice is to use * only for public APIs. Listing your own site is just as easy and safer.

Step 2: the Vite React app

From the my-app folder, create the frontend with Vite's React template:

npm create vite@latest web -- --template react --no-immediate
cd web
npm install

--no-immediate stops Vite from installing and starting the dev server straight away. If it still asks whether to install and start now, choose no: we install on the next line.

Replace web/src/App.jsx with a component that calls the API:

import { useEffect, useState } from 'react'

const API_URL = import.meta.env.VITE_API_URL

export default function App() {
  const [message, setMessage] = useState('Waking up the API, the first request can take about a minute...')

  useEffect(() => {
    fetch(`${API_URL}/api/hello`)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then((data) => setMessage(data.message))
      .catch((err) => setMessage(`Could not reach the API (${err.message}). If it was asleep, reload in a minute.`))
  }, [])

  return (
    <main>
      <h1>Vite + React on Vercel</h1>
      <p>{message}</p>
    </main>
  )
}

The API address is never written in the code. It comes from import.meta.env.VITE_API_URL. For local work, create web/.env.development:

VITE_API_URL=http://localhost:3000

With the API still running in another terminal, start the dev server with npm run dev and open http://localhost:5173. The heading appears, and a moment later the message from Express replaces the loading text.

Two rules from Vite's docs explain most deployment bugs:

  • Only variables starting with VITE_ reach your React code. A variable called API_URL is simply undefined in the browser. The prefix is a safety catch so database passwords don't end up in the bundle by accident.
  • The value is baked in when you build. vite build pastes the text of VITE_API_URL into the JavaScript file. We checked: building with VITE_API_URL=https://my-api.onrender.com puts that exact address in dist/assets/index-*.js. So never put a secret in a VITE_ variable: anyone can read it in the browser.

.env.development is only read by the dev server. vite build runs in production mode, so on Vercel the value comes from Vercel's own environment variables (Step 6).

Step 3: add vercel.json for client-side routes

If your app uses React Router (or any client-side routing), there is no real about.html file on Vercel, only index.html. Clicking a link works, because React changes the page in the browser. But refreshing /about asks Vercel for a file that doesn't exist, and you get a 404. Vercel's Vite guide gives the fix. Create web/vercel.json:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

Every path now gets index.html, and React Router shows the right page. Your JavaScript and CSS still load normally, because Vercel serves real files before it applies rewrites.

Step 4: push to GitHub

The Vite template already has a .gitignore inside web. Add one at the top of my-app with a single line, node_modules, so the server's packages stay out of the repo too. Then push:

git init
git add .
git commit -m "Vite React frontend and Express API"
git branch -M main
git remote add origin https://github.com/YOUR-NAME/my-app.git
git push -u origin main

Both Vercel and Render redeploy automatically on every push to this branch from now on.

Step 5: deploy the Express API on Render

Deploy the API first, because the frontend needs its address.

  1. In the Render Dashboard, click New > Web Service and pick Git Provider. Connect GitHub if you haven't, then choose your repo.
  2. Fill in the form. Name becomes part of your address (my-api gives something like my-api.onrender.com). Choose the Region closest to most of your users, branch main, language Node.
  3. Set the root directory to server, so Render builds only that folder. Render's monorepo guide shows the Root Directory setting under the service's Build & Deploy settings. If you change it after creating the service, Render asks you to confirm the commands, which then run inside server.
  4. Build Command: npm install. Start Command: npm start.
  5. Choose the Free compute plan, then click Create Web Service.

When the deploy finishes, open https://YOUR-SERVICE.onrender.com/api/hello. You should see the same JSON as on your laptop. Copy that address without /api/hello and without a trailing slash: it's your VITE_API_URL.

Upgrading the API from the 2023 video? Render's docs say services created before November 2023 default to Node.js 14.17.0 if you don't choose a version, and Express 5 needs Node.js 18 or newer. Keep the "engines": { "node": "24.x" } line, or set a NODE_VERSION environment variable on the service.

Step 6: deploy the React app on Vercel

  1. In the Vercel dashboard, click New Project and import the same repo.
  2. On the configure page, set the root directory to web. Vercel should pick the Vite framework preset by itself, which sets the build command and the dist output folder.
  3. Open Environment Variables and add VITE_API_URL with your Render address, for example https://my-api.onrender.com.
  4. Click Deploy.

Vercel gives the project an address based on its name, ending in .vercel.app, for example https://my-app.vercel.app. Use this short production address, not a longer address for one particular deployment: it's the one that goes in CORS_ORIGIN in the next step. Open it. You'll probably see "Could not reach the API" for now, and the browser console shows a CORS error. That's expected: Express doesn't know about the Vercel site yet.

Vercel's docs say changes to environment variables apply only to new deployments. If you add or fix VITE_API_URL later, redeploy (or push a commit) before you test again.

Step 7: connect them with CORS_ORIGIN

Back on Render, open your service, click Environment, and add a variable:

  • Key: CORS_ORIGIN
  • Value: your Vercel address, for example https://my-app.vercel.app. No path, no trailing slash.

Save it with Save and deploy so the server restarts with the new value. Wait until the deploy finishes (the service's Deploys page shows its progress), then reload your Vercel site: the message "Hello from Express on Render!" appears. Reload too early and you'll still see the old error.

Need more than one site, say a custom domain too? Separate them with commas: https://my-app.vercel.app,https://www.example.com. Our code trims spaces, so a, b also works.

Why is the Render API slow at first? The free tier's one-minute wait

Here's what Render's free instance docs say, as of October 2026:

  • "Render spins down a Free web service that goes 15 minutes without receiving any inbound traffic."
  • It spins back up on the next request, and "this process takes about one minute". Render shows a loading page to browsers that open the service while it wakes.
  • Each workspace gets 750 free instance hours per calendar month. A spun-down service doesn't use them.
  • The filesystem isn't kept: files your app writes are lost when it redeploys, restarts or spins down. Store data in a database, not on disk.

For your users, this means the first visit after a quiet spell is slow. The page from Vercel still loads quickly, but the API isn't ready until Render has woken it. Render's docs don't say exactly what a fetch() from another site receives during that minute, so plan for both cases: a long wait, or an error on the first try. That's why our useState starts with "Waking up the API...", and why the error message tells the visitor to reload in a minute instead of leaving them thinking the site is broken. Render itself says free instances are for testing and hobby projects, not production. For a real product, move the service to a paid instance, which doesn't spin down.

The same goes for Vercel's free plan: Vercel's docs say the Hobby plan is for "non-commercial, personal use only". For a college project or a portfolio, both free plans are fine.

How do you keep a free Render API awake?

A popular trick is to ping the API from an outside service (an uptime checker or a scheduled job) more often than every 15 minutes, so it never goes idle long enough to spin down. It works, but it isn't free in the way it looks:

  • It spends your free hours. Render's docs say a free web service "consumes these hours as long as it's running", and a spun-down one doesn't. Kept awake for a 31-day month, one service runs 31 × 24 = 744 hours. Your workspace gets 750, so there are only 6 hours to spare.
  • Two awake services run out. Two services use 48 hours a day, and 750 ÷ 48 is about 15.6, so the hours are gone on day 16. Then, in Render's words, it "suspends all of your Free web services until the start of the next month". That means every free service in the workspace, not just the ones you pinged.
  • It doesn't stop restarts. Render's docs also say it "might restart a Free web service at any time", and files on its disk are lost each time that happens.

Neither Render's free instance docs nor its Acceptable Use Policy mentions pinging by name. The policy does forbid any attempt to "bypass, circumvent, or defeat any access or usage restrictions", and Render doesn't say whether a keep-awake ping counts. Our advice: for a portfolio or a class project, let it sleep and keep the "Waking up the API..." message. If real users need a fast first answer, a paid instance is the honest fix.

Common deployment errors and fixes

Common deployment errors: CORS error, fix CORS_ORIGIN on Render; 404 NOT_FOUND on refresh, fix with a vercel.json rewrite; undefined API URL, fix with the VITE_ prefix and a redeploy

1. "Blocked by CORS policy"

Chrome shows something like this in the console:

Access to fetch at 'https://my-api.onrender.com/api/hello' from origin 'https://my-app.vercel.app'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Firefox says Reason: CORS header 'Access-Control-Allow-Origin' missing. The request did reach Express, and Express answered. The browser just won't let your code read an answer without the header. Check, in this order:

  • CORS_ORIGIN on Render matches the address in your browser's address bar exactly: https, no trailing slash, no path. We tested it: with https://my-app.vercel.app/ (one extra slash), Express sends no header at all.
  • You saved the variable with a deploy, so the running server has it.
  • You're opening the same address you listed. Test on the production address: Vercel's preview deployments have their own addresses, so they show the CORS error unless you add each one.

2. "404: NOT_FOUND" when you refresh a page

The home page works, links work, but refreshing /about or opening it from a bookmark shows Vercel's 404 page with the code NOT_FOUND. That's the client-side routing problem from Step 3. Add vercel.json with the rewrite to /index.html, make sure it's inside web (your root directory, not the repo root), and push.

3. The API URL is undefined

The page says Could not reach the API (HTTP 404), or this, followed by our "reload in a minute" hint:

Could not reach the API (Unexpected token '<', "<!doctype "... is not valid JSON).

Open the Network tab and you'll see a request to https://my-app.vercel.app/undefined/api/hello. VITE_API_URL was missing when Vercel built the app, so the URL started with the word undefined and pointed back at Vercel. With the rewrite in place, Vercel answers with index.html, and res.json() chokes on the first <. Causes:

  • The variable is named API_URL or REACT_APP_API_URL (the old Create React App prefix). Vite ignores both. We built the app with only API_URL set, and the address was nowhere in the bundle.
  • The variable was added after the last deploy. Redeploy.
  • The variable is only set for Preview and you're on the Production deployment, or the other way round. In Vercel, each variable is applied to the environments you select.

4. 502 Bad Gateway or a failed deploy on Render

Render's troubleshooting guide says a 502 usually means the web service isn't listening where Render expects: bind to 0.0.0.0 and use the PORT variable. app.listen(port) with process.env.PORT does both. If the build fails with something like SyntaxError: Unexpected token '??=', Render's guide says your Node.js version is too old for the code: check the engines field.

How do you add MongoDB Atlas to the Express API?

Render's free instances forget files on disk, so real data belongs in a database. MongoDB Atlas has a Free cluster: MongoDB's docs list 0.5 GB of storage and 100 operations per second, running MongoDB 8.0. That's plenty for a project like this one.

Set up Atlas

  1. In Atlas, create a Free cluster. Pick a region close to your Render region, because the API talks to the database on every request. Render's Atlas guide pairs them up, for example Render's Frankfurt region with AWS Frankfurt (eu-central-1).
  2. Create a database user with a username and password. Save the password somewhere safe.
  3. Under Network Access, let Render in (next section).
  4. Open the cluster's Connect options, choose the Node.js driver, and copy the connection string. It looks like mongodb+srv://USER:PASSWORD@cluster0.example.mongodb.net/.
  5. On Render, open your service, click Environment, add MONGODB_URI with the whole string as its value, and save with a deploy. Like CORS_ORIGIN, it lives only on Render. Never put it in a VITE_ variable: those end up in the browser.

One trap: if your password has any of these characters, $ : / ? # [ ] @, MongoDB's docs say they "must be converted using percent encoding". An @ becomes %40, for example. The easy way out is a long password made of letters and numbers only.

Network access: let Render connect

MongoDB's docs say "Atlas only allows client connections to the cluster from entries in the project's IP access list". Until Render's address is on that list, every database call fails. You have two choices:

  • Render's outbound IP ranges. Render's Atlas guide says to add your service's outbound IP addresses. Open the service in the Render Dashboard, open the Connect menu, switch to the Outbound tab and copy the ranges into Atlas. Know what this buys you: Render says "Outbound IP ranges are shared across all services in the same region", so other people's Render apps send from the same addresses. It narrows things down, but your password still does the real work.
  • 0.0.0.0/0, which means anywhere. It always works. MongoDB warns that it "can expose your deployment to unauthorized access", and Atlas emails everyone on the project when you add it. Use it only with a long random password, ideally just while you test.

Need fixed addresses that are only yours? Render's docs say you can buy dedicated outbound IPs for your workspace.

Connect from Express

In the server folder, install MongoDB's Node.js driver:

npm install mongodb

Create server/db.js:

import { MongoClient } from 'mongodb'

const uri = process.env.MONGODB_URI
if (!uri) {
  throw new Error('MONGODB_URI is not set. Add it on Render under Environment.')
}

const client = new MongoClient(uri)
export const db = client.db('myapp')

If the variable is missing, the server stops at once with that message in Render's logs, instead of failing later with a confusing error. There's no connect() call: MongoDB's docs say the driver "automatically calls the MongoClient.connect() method" the first time you read or write.

In server/index.js, add this line under the other two imports:

import { db } from './db.js'

Then add two routes under the /api/hello route:

app.use(express.json())

app.get('/api/notes', async (req, res) => {
  const notes = await db.collection('notes').find().sort({ _id: -1 }).limit(20).toArray()
  res.json(notes)
})

app.post('/api/notes', async (req, res) => {
  const text = req.body?.text
  if (typeof text !== 'string' || text.trim() === '') {
    return res.status(400).json({ error: 'Send JSON like {"text": "Buy milk"}' })
  }
  const result = await db.collection('notes').insertOne({ text: text.trim() })
  res.status(201).json({ _id: result.insertedId, text: text.trim() })
})
  • express.json() reads JSON request bodies. Without it, req.body is undefined: see req.body is undefined in Express 5 for the details.
  • The POST route checks the input before it touches the database. Anything that isn't a non-empty string gets a 400.
  • If Atlas can't be reached, the await fails. Express 5 catches a failed async route by itself (its migration guide calls this "forwarding the rejected value as an Error"), so the visitor gets a 500 error and the server keeps running.

We ran this code against MongoDB 8.0, the version Atlas uses for Free clusters, with version 7.7.0 of the mongodb package: posting {"text": "Buy milk"} returned 201, and GET /api/notes listed the note. We couldn't test against a real Atlas cluster from our check script, so the Network Access steps come from the Atlas and Render docs.

Option B: serve the React build from Express on Render (no CORS)

You don't have to use two hosts. Express can send the React files too, so one Render web service does everything. The page and the API then come from the same address, the browser sees one origin, and CORS never comes up. The trade-off: the whole site, not just the API, sleeps on the free plan, and you lose Vercel's preview build for every branch.

Replace server/index.js with this:

import path from 'node:path'
import express from 'express'

const app = express()
const dist = path.join(import.meta.dirname, '..', 'web', 'dist')

app.get('/api/hello', (req, res) => {
  res.json({ message: 'Hello from Express on Render!' })
})

// Unknown API paths get a JSON 404, not the React page
app.use('/api', (req, res) => {
  res.status(404).json({ error: 'Not found' })
})

// The React build: index.html plus the files in dist/assets
app.use(express.static(dist))

// Any other path, like /about, gets index.html so React can show the page
app.get('/{*splat}', (req, res) => {
  res.sendFile(path.join(dist, 'index.html'))
})

const port = process.env.PORT || 3000
app.listen(port, () => {
  console.log(`App listening on port ${port}`)
})
  • The order matters. API routes come first. Next, any other /api path gets a JSON 404, so a typo in a URL gives a clear error instead of the React page. Then express.static sends real files from web/dist. Last, every other path gets index.html. That last route does the job vercel.json did on Vercel.
  • '/{*splat}' is Express 5 syntax. Express's migration guide says the wildcard * "must have a name", and wrapping it in braces makes it match / as well. The old Express 4 line app.get('*', ...) now crashes at startup with TypeError: Missing parameter name at index 1: *.
  • import.meta.dirname is the folder that index.js is in, so the path to web/dist is right whichever folder Render starts in. Express's docs warn that a plain express.static path "is relative to the directory from where you launch your node process".

In web/src/App.jsx, change one line so the API address falls back to "this same site":

const API_URL = import.meta.env.VITE_API_URL ?? ''

With no VITE_API_URL set, fetch() now asks for /api/hello on the same address. We checked the build: the API address becomes an empty string.

On Render, create one Web Service from the repo:

  • Root Directory: leave it empty. Render's docs say "Files outside your service's root directory are not available to the service at build time or at runtime", and this build needs both web and server.
  • Build Command: cd web && npm install && npm run build && cd ../server && npm install
  • Start Command: cd server && npm start
  • No VITE_API_URL and no CORS_ORIGIN.

Vite is in the devDependencies of web/package.json. If you ever set NODE_ENV to production on the service, npm's docs say it "will not install modules listed in devDependencies", and the build fails because Vite isn't installed. Use npm install --production=false in web in that case.

To try it on your laptop, run npm run build in web, then npm start in server, and open http://localhost:3000. Refreshing /about works too.

Should the Express API go on Vercel or Render?

Vercel can run the Express API as well (see the FAQ below), so why use Render at all? It's still the better fit in three cases:

  • Slow requests. On Vercel's free Hobby plan, a request can run for at most 300 seconds. Render says its web services "allow HTTP responses to take up to 100 minutes".
  • Live connections (WebSockets), like a chat or a multiplayer game. Vercel's WebSockets are in beta, and its docs say "WebSocket connections close when a Vercel Function reaches its maximum duration". Render says its web services "support WebSockets on long-lived service instances". On the free plan, WebSocket messages even count as traffic, so a chat that's in use keeps the service awake.
  • Serving the React build from Express, as in Option B. Vercel's docs say express.static() is ignored there, so that setup doesn't work on Vercel as written.

Your API works at https://my-api.onrender.com/api/hello, and your site is at https://my-app.vercel.app. You set CORS_ORIGIN to https://my-app.vercel.app/ and VITE_API_URL to https://my-api.onrender.com. The site still says "Could not reach the API". Which variable is wrong, and what does the console show?

Show the answer

CORS_ORIGIN has a trailing slash. The browser sends Origin: https://my-app.vercel.app with no slash, so the strings don't match and Express sends no Access-Control-Allow-Origin header. The console shows the "blocked by CORS policy" error. Remove the slash and save with a deploy. VITE_API_URL is fine.

Questions people ask

Why can't I just use process.env in my Vite React app?

Your React code runs in the browser, which has no process. Vite gives you import.meta.env instead, and only exposes variables that start with VITE_. If you're moving from Create React App, rename REACT_APP_API_URL to VITE_API_URL and update the code that reads it.

Can I put an API key in a VITE_ variable?

No. Vite's docs say these values "are bundled into your source code at build time", so anyone can read them in the browser. Keep secrets on Render as normal environment variables (without the prefix) and let Express call the other service for you.

Can I avoid CORS completely?

Yes. Vercel can rewrite requests to an external origin, acting as a proxy. A rewrite from /api/(.*) to your Render address makes the browser see one origin, so no CORS check happens. It's a good option once you understand the basic setup. The CORS approach in this post keeps the two hosts independent and makes errors easier to read.

Why is my site slow the first time I open it?

The Vercel part isn't slow. The Render free instance spins down after 15 minutes without traffic and takes about a minute to start again, as of October 2026. Show a loading message, or move the service to a paid instance.

Can the Express API go on Vercel too?

Yes. Since September 2025, Vercel has deployed Express apps with zero configuration: no vercel.json and no /api folder. The whole app becomes one Vercel Function that scales with traffic. Know the limits first: express.static() is ignored (static files go in public/), and on the free Hobby plan a request can run for at most 300 seconds. Render is still the simpler choice when you want one ordinary, long-running Node server, and that is what this post uses.

Can I host the React app on Render too?

Yes. Render's docs say static sites are free to deploy on Render. Vercel is used here because it detects Vite automatically and builds a preview for every branch. The CORS and VITE_API_URL steps are the same either way.

Keep going

Checked on 20 October 2026 against the 35 sources below.

Sources

Every file in this post was run before publishing: the Express API in Node.js 24 (JSON and CORS headers checked), npm run build for the Vite app, with and without the VITE_ prefix, the Option B server (home page, a deep link, the API and a missing API path), and the MongoDB routes against a local MongoDB 8.0.