What you'll learn
By the end of this tutorial you'll be able to deploy a full MERN application to production-grade cloud services. You'll understand the three-service deployment topology (Atlas for the database, Render for Express, Vercel for React), how to configure environment variables for each service, how to fix the CORS issues that always appear between deployed services, and how to verify the full stack is working after every deploy.
Deployment is where many MERN projects stall — not because any individual step is hard, but because the order of operations isn't obvious and the error messages when something goes wrong aren't always helpful. This tutorial goes in the right order.
Who this is for
- Developers who have a working MERN app on localhost and need to put it on the internet
- Anyone who has tried to deploy before and hit confusing errors around CORS, environment variables, or Atlas connection strings
- Self-taught developers building portfolio projects for job applications who need a live URL
You can skip this if you're deploying with Docker on your own VPS or a cloud VM — that workflow differs significantly from the managed platform approach covered here. Come back if you want to switch to platform-as-a-service later.
What is the MERN deployment topology?
A deployed MERN app runs on three separate services: MongoDB Atlas (a managed cloud database), a Node.js hosting platform where Express runs, and a static hosting platform where the React build is served. They communicate over the internet rather than over localhost.
Plain English: in development, everything runs on your laptop on localhost. In production, each layer runs on a server in a data center. The only thing that changes in your actual code is the URLs — environment variables carry them so you don't hardcode them.
Simple idea: three services, one job each — Atlas stores data, Render runs your API, Vercel serves your frontend. HTTP connects them, the same way localhost connects them in development.
Prerequisites
- A working MERN app running on localhost (all four layers functional, tested locally)
- A GitHub account (Render and Vercel both deploy from GitHub repositories)
- A MongoDB Atlas account (free tier is sufficient to start)
- Your code in a git repository, ideally with
server/andclient/in separate folders
Setup from zero
Step 1 — Set up MongoDB Atlas
- Sign in at cloud.mongodb.com
- Create a new Project → create a free M0 Shared cluster in a region close to where you'll host the API
- Under Database Access, create a database user with a strong username and password — save both
- Under Network Access, add only your current IP so you can test locally. Do not leave
0.0.0.0/0enabled for convenience; that permits connection attempts from every public IP - Click Connect → Drivers → copy the connection string
The string looks like:
mongodb+srv://youruser:yourpassword@cluster0.abc123.mongodb.net/yourdb?retryWrites=true&w=majority
Replace the placeholders with your actual values. This is your MONGO_URI for the next step.
> Little tip: Never paste your Atlas connection string directly into code or commit it to GitHub. Atlas strings contain your database password. If it ends up in a public repo, rotate it immediately from the Atlas dashboard — Database Access → Edit → Generate New Password.
Step 2 — Deploy the Express backend on Render
1. Go to render.com and create a free account, connecting your GitHub
2. New → Web Service → select your repository
3. Set Root Directory to server
4. Build Command: npm install
5. Start Command: node index.js (or your entry file)
6. Under Environment Variables, add all of these:
- MONGO_URI — your Atlas connection string
- JWT_SECRET — a fresh long random string (generate a new one for production; never reuse the development value)
- NODE_ENV — production
- CLIENT_URL — leave blank for now; you'll fill this after Vercel deploys
7. Click Create Web Service. From that service's Connect → Outbound panel, copy its outbound CIDR ranges into Atlas Network Access, then redeploy after Atlas accepts the entries
Render gives you a URL like https://your-app-name.onrender.com. Visit https://your-app-name.onrender.com/api/posts to confirm it returns JSON. If it does, the backend is live.
Step 3 — Deploy the React frontend on Vercel
1. Go to vercel.com and sign in with GitHub
2. Add New → Project → import your repository
3. Set Root Directory to client
4. Vercel auto-detects Vite and Create React App — build settings are filled in automatically
5. Under Environment Variables, add:
- VITE_API_URL (for Vite) or REACT_APP_API_URL (for Create React App) — set to your Render URL: https://your-app-name.onrender.com
6. Click Deploy
Once Vercel gives you a URL (e.g., https://your-app.vercel.app), go back to Render and set CLIENT_URL to that URL. Redeploy the Render service. Now both sides know about each other.
> Little tip: Prefix Vite environment variables with VITE_ and Create React App variables with REACT_APP_. These prefixes expose values to the browser bundle, so use them for public configuration such as an API base URL — never for database passwords, JWT secrets, or private API keys.
The mental model
Think of deployment as replacing all localhost references with real URLs, then telling each service to trust the others.
Development configuration:
- React fetches from http://localhost:5000
- Express connects to mongodb://localhost:27017
- CORS allows everything because it's your own machine
Production configuration:
- React reads VITE_API_URL → https://your-api.onrender.com
- Express reads MONGO_URI → Atlas connection string
- CORS allows only https://your-app.vercel.app
Your application logic doesn't change. Only the configuration values change, and environment variables are what carry those values safely between environments.
Key terms
Environment variable — a named value set outside the code, read at runtime with process.env.NAME (server) or import.meta.env.VITE_NAME (Vite). The mechanism for keeping URLs and secrets out of source code.
CORS (Cross-Origin Resource Sharing) — a browser security policy that blocks requests from one origin (e.g., https://your-app.vercel.app) to a different origin (e.g., https://your-api.onrender.com) unless the server explicitly sends the right headers permitting it.
Build output — the dist/ or build/ folder created when you run npm run build in the React project. This contains optimized static HTML, JavaScript, and CSS. Vercel serves this folder — not your source files.
Health check endpoint — a simple route that returns 200 to signal the server is alive. GET /health returning { status: "ok" } is the conventional form. Monitoring tools and load balancers call this to detect crashes.
Cold start — Render documents that a free web service spins down after 15 minutes without inbound traffic and takes about a minute to spin back up. That tier is useful for demos and testing, not a production service with response-time expectations.
Step-by-step: production CORS configuration
Update your Express CORS setup to allow only known origins:
// server/index.js — production-ready CORS
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
process.env.CLIENT_URL,
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// allow requests with no origin (curl, Postman, server-to-server)
if (!origin || allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error(`CORS: origin ${origin} not allowed`));
},
credentials: true,
}));
Update your React API client to read the URL from an environment variable:
// client/src/api/client.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:5000";
export async function apiFetch(path: string, options?: RequestInit) {
const res = await fetch(`${BASE_URL}${path}`, options);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
if (res.status === 204) return null;
const contentType = res.headers.get("content-type") ?? "";
return contentType.includes("application/json")
? res.json()
: res.text();
}
Add a health check to your Express server:
app.get("/health", (req, res) =>
res.json({ status: "ok", timestamp: new Date().toISOString() })
);
Patterns
Log the database connection on startup — make Atlas connection success or failure visible immediately in Render's logs:
mongoose.connect(process.env.MONGO_URI)
.then(() => {
console.log("MongoDB connected");
app.listen(process.env.PORT || 5000, () => console.log("Server running"));
})
.catch(err => {
console.error("MongoDB connection failed:", err.message);
process.exit(1);
});
Use process.env.PORT for the server port — Render sets this automatically. Hardcoding 5000 works locally but may conflict on the platform:
app.listen(process.env.PORT || 5000);
Let the platform supervise the Node process — a managed web service starts and restarts the command you configure, so adding PM2 inside Render is unnecessary. Use a process manager such as PM2 or systemd when you operate your own VM instead.
Common mistakes
Hardcoded localhost in React fetches — if any React file contains fetch("http://localhost:5000/...") without reading from an environment variable, production requests will try to reach your laptop from Vercel. Always use VITE_API_URL or REACT_APP_API_URL.
Forgetting to set environment variables on Render — the service deploys successfully but crashes on first request because process.env.MONGO_URI is undefined. Render's logs will show a connection error in the first few lines. Environment variables are always the first thing to check.
Atlas cannot accept the Render connection — Render exposes the service's outbound CIDR ranges in Connect → Outbound. Add those ranges to the Atlas project IP access list. They may be shared ranges rather than dedicated IPs, but they are still narrower and safer than 0.0.0.0/0.
Troubleshooting
React shows a blank page on Vercel — open the browser console. Almost every blank-page issue is a JavaScript runtime error. The most common causes: VITE_API_URL not set in Vercel environment variables, or an unhandled fetch error thrown during initial render.
Render logs show MongooseServerSelectionError on startup — the Express server can't reach Atlas. Check that MONGO_URI is set correctly in Render's Environment section. Copy the exact connection string directly from the Atlas Connect dialog to avoid typos.
CORS error in the browser — "Access to fetch at ... has been blocked by CORS policy". The Vercel origin isn't in the allowedOrigins array, or CLIENT_URL isn't set in Render's environment. Check both. The exact origin matters — https://your-app.vercel.app and https://your-app.vercel.app/ (trailing slash) are treated differently.
Rollback and post-deploy verification
Keep the previous successful deployment identifiable before releasing a new commit. After deployment, use the public HTTPS URLs to call GET /health, load the frontend, complete one important user flow, inspect the browser Network and Console panels, and check server logs for startup or request errors.
If a regression appears, select the last known-good deployment in the provider dashboard instead of debugging on the live release. Roll back application code first; database changes need a separately planned, backward-compatible recovery path. Never assume rolling back code can undo a destructive migration.
Checklist
- [ ] MongoDB Atlas cluster created with a least-privilege database user and only required IP/CIDR access
- [ ]
MONGO_URIenvironment variable set on Render - [ ] Fresh
JWT_SECRET— not the development value — set on Render - [ ]
CLIENT_URLset on Render pointing to the Vercel deployment URL - [ ] Express CORS allows the Vercel origin via
CLIENT_URL - [ ] React reads API URL from
VITE_API_URLorREACT_APP_API_URL, never hardcoded - [ ]
VITE_API_URLset on Vercel pointing to the Render service URL - [ ]
GET /healthendpoint returns 200 on the deployed API - [ ] Frontend and API use HTTPS with no mixed-content browser errors
- [ ] Browser console shows no errors on the deployed Vercel app
- [ ] Render startup/request logs checked after deployment
- [ ] Full user flow tested end-to-end on the live URLs
- [ ] Last known-good deployment identified and rollback path understood
Practice task
Deploy the notes app from the previous tutorials. Verify the complete flow on the live deployment: create an account, log in, create a note, confirm the note persists after a full page refresh, and confirm the note list only shows your notes. Check Render's logs for the "MongoDB connected" and "Server running" lines. Check the browser console for any errors throughout.
FAQ
Is Render's free tier production-ready?
Render describes free instances as suitable for testing, hobby projects, and previews — not production applications. They spin down when idle and have other limits. For real users, review the current paid instance options and operational requirements on Render's pricing and service documentation rather than relying on a fixed price copied into a tutorial.
Should I serve React from Express instead of using Vercel?
You can — Express can serve React's dist/ folder as static files, consolidating to one deployed service. This simplifies deployment but couples frontend and backend deploys. Separate deployments on Vercel and Render are cleaner for most projects and let you deploy frontend changes without touching the backend.
How do I set up continuous deployment so new pushes auto-deploy?
Git-connected projects can create deployments from branch pushes. Verify the production branch and auto-deploy settings in both dashboards, and require your tests to pass before merging to that branch.
What to learn next
After a stable deployment: adding a custom domain (Vercel and Render both support custom domains with free SSL), setting up uptime monitoring with a free tool like UptimeRobot, and structuring a CI pipeline with GitHub Actions for automated testing before deploys.
Takeaways
MERN deployment is three services replacing three localhost addresses: Atlas replaces your local MongoDB, Render replaces localhost:5000, Vercel replaces localhost:5173. Environment variables carry the URLs; CORS configuration tells Express which origins to trust. The application code stays identical — only the configuration values change between development and production.
If you remember only one thing: every hardcoded localhost URL in your React code is a production bug waiting to happen. Replace every single one with an environment variable before you deploy — it takes minutes and saves hours of confused production debugging.