Deploying a MERN application means taking three separate concerns โ the database, the API server, and the React frontend โ and hosting each in the right environment for its purpose. MongoDB runs best on a managed cloud service (Atlas) that handles replication, backups, and scaling. The Express API runs on a Node.js host (Render, Railway, or a VPS) that can execute server code. The React build output is static HTML, CSS, and JavaScript that can be served from a global CDN (Netlify, Vercel, or Cloudflare Pages) at essentially zero cost and with near-instant load times worldwide. Understanding this architecture โ and how the three pieces communicate through environment variables and CORS โ is the foundation for everything in this chapter.
Production Architecture Overview
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ MERN Blog Production โ
โ โ
โ Browser โ
โ โ โ
โ โโโโ https://mernblog.netlify.app (React SPA) โ
โ โ Netlify CDN ยท Vite build output ยท _redirects fallback โ
โ โ โ
โ โโโโ https://api.mernblog.onrender.com (Express API) โ
โ Render Node.js service ยท PORT=10000 โ
โ MONGODB_URI โ MongoDB Atlas cluster โ
โ JWT_SECRET, CLOUDINARY_*, EMAIL_*, ... โ
โ โ
โ MongoDB Atlas (M0 free tier) โ
โ ยท mernblog.cluster0.mongodb.net โ
โ ยท Network access: Render service IP + your dev IP โ
โ ยท Database user: mernblog-prod (strong password) โ
โ ยท Automated backups enabled โ
โ โ
โ Cloudinary CDN (images, avatars, cover photos) โ
โ Resend / SendGrid (transactional email) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
VITE_API_URL=https://api.mernblog.onrender.com to know where to send API requests..nvmrc or Render’s environment settings), the same MongoDB major version (Atlas lets you choose), and the same npm package versions (commit package-lock.json). The more different production is from development, the more likely you are to encounter “works on my machine” bugs.MONGODB_URI, JWT_SECRET, CLOUDINARY_API_SECRET, email passwords โ must be set in your hosting platform’s environment variable UI, not in any file that is committed to the repository. Add .env, .env.local, and .env.production to .gitignore before your first commit and never remove them.Environment Variables by Layer
| Layer | Variable | Value in Production | Set In |
|---|---|---|---|
| React (Vite) | VITE_API_URL |
https://api.mernblog.onrender.com |
Netlify โ Site settings โ Env vars |
| Express | MONGODB_URI |
Atlas connection string | Render โ Environment |
| Express | JWT_SECRET |
64-char random hex | Render โ Environment |
| Express | JWT_EXPIRES_IN |
7d |
Render โ Environment |
| Express | CLIENT_URL |
https://mernblog.netlify.app |
Render โ Environment |
| Express | NODE_ENV |
production |
Render โ Environment |
| Express | CLOUDINARY_* |
From Cloudinary dashboard | Render โ Environment |
| Express | RESEND_API_KEY |
From Resend dashboard | Render โ Environment |
Production CORS Configuration
// server/index.js โ production-ready CORS
const corsOptions = {
origin: (origin, callback) => {
const allowed = [
process.env.CLIENT_URL, // https://mernblog.netlify.app
'http://localhost:5173', // local dev (optional โ remove in strict prod)
].filter(Boolean);
// Allow requests with no origin (Postman, server-to-server)
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: ${origin} not allowed`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // preflight for all routes
Production vs Development Differences
| Concern | Development | Production |
|---|---|---|
| Database | Local MongoDB or Atlas free tier | Atlas M0 free or paid cluster |
| API URL | Vite proxy โ localhost:5000 | https://api.mernblog.onrender.com |
| Ethereal (fake SMTP) | Resend or SendGrid | |
| File storage | Local disk (or Cloudinary) | Cloudinary CDN |
| Error messages | Full stack trace in response | Generic message (stack hidden) |
| Logging | Console | Console + structured (Render captures) |
| HTTPS | HTTP is fine | Always HTTPS โ cookies, JWT |
Common Mistakes
Mistake 1 โ Hardcoding the API URL in React
โ Wrong โ URL hardcoded, breaks in production:
const api = axios.create({ baseURL: 'http://localhost:5000/api' }); // breaks in prod!
โ Correct โ use Vite environment variable:
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api',
}); // โ different per environment
Mistake 2 โ Forgetting to set NODE_ENV=production
โ Wrong โ NODE_ENV undefined โ Express shows full error stack traces to users, uses dev email transporter.
โ
Correct โ set NODE_ENV=production in the Render environment variables. This enables error message sanitisation, production email transport, and performance optimisations in Express and other libraries.
Mistake 3 โ Not adding a health check endpoint
โ Wrong โ hosting platforms cannot verify the API is running:
// No health endpoint โ Render uses root / which may redirect or return HTML
โ Correct โ add a simple health check route:
app.get('/api/health', (req, res) => res.json({ status: 'ok', env: process.env.NODE_ENV }));
// Render health check URL: https://api.mernblog.onrender.com/api/health โ
Quick Reference โ Hosting Platforms
| Service | What to Host | Free Tier |
|---|---|---|
| MongoDB Atlas | Database | M0: 512MB storage, shared cluster |
| Render | Express API (Node.js) | 750 hrs/month, sleeps after 15min idle |
| Netlify | React SPA (static) | 100GB bandwidth, 300 build min/month |
| Cloudinary | Image storage + CDN | 25GB storage, 25GB bandwidth |
| Resend | Transactional email | 3,000 emails/month |