Deploying a Full-Stack MERN App on Vercel — The Things Nobody Tells You
Vercel's free tier is genuinely excellent for MERN apps — but there are a handful of gotchas that will waste your afternoon if you don't know about them. This is what I learned deploying a dozen of these.
I've deployed probably a dozen full-stack projects to Vercel over the past two years. Express APIs, Next.js apps, MERN stacks. Most of them were for clients who needed a live URL fast without paying for a VPS.
Vercel's free tier is legitimately good. But there are a handful of gotchas that will waste your afternoon if nobody told you about them. Consider this the briefing I wish I'd had.
The Architecture Decision You Need to Make First
There are two ways to run a MERN stack on Vercel:
Option A: Separate repos — React frontend on Vercel, Express backend on Railway or Render, MongoDB Atlas as always. Simple, familiar, but you're managing two deployments and dealing with CORS.
Option B: Full Next.js — Migrate your Express routes to Next.js Route Handlers. One repo, one domain, no CORS. This is what I now default to for new projects.
If you have an existing Express backend with complex middleware, Option A is the faster path. If you're starting fresh or can afford the migration time, Option B is cleaner. This post covers Option B since that's where Vercel shines.
MongoDB Connection — The Serverless Gotcha
This is the one that gets everyone. In a normal Node.js server, you connect to MongoDB once when the server starts. Serverless functions don't work like that — each request can spin up a new function instance.
If you do this:
// Wrong — creates a new connection on every request
import mongoose from 'mongoose';
export async function GET() {
await mongoose.connect(process.env.MONGODB_URI);
// ...
}
You'll hit MongoDB Atlas's connection limit (500 on the free tier) within minutes under any real traffic. The fix is connection caching:
// Right — reuses the existing connection
let cached = global._mongooseCache;
if (!cached) {
cached = global._mongooseCache = { conn: null, promise: null };
}
export async function connectDB() {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(process.env.MONGODB_URI, {
bufferCommands: false,
});
}
cached.conn = await cached.promise;
return cached.conn;
}
Put this in src/lib/db.js and call await connectDB() at the top of every route handler. The global cache survives between warm function invocations on the same Vercel instance.
Environment Variables — Don't Use NEXT_PUBLIC_ for Secrets
Any variable prefixed with NEXT_PUBLIC_ gets bundled into your frontend JavaScript and is visible to anyone who opens DevTools. Keep your MongoDB URI, JWT secret, and API keys in unprefixed variables — they stay server-side only.
In Vercel: go to your project → Settings → Environment Variables. Add them there. They're not in your repo, so they survive across deploys.
For local development, use .env.local — Next.js loads this automatically and it's gitignored by default.
File Uploads Don't Work the Way You Think
Vercel's serverless functions are stateless — there's no persistent filesystem. If you try to use Multer's disk storage to write files to /uploads, they'll vanish after the function returns. Two options:
Cloudinary (what I use): Accept the file in the route handler, convert to a buffer, upload directly via their API.
export async function POST(request) {
const formData = await request.formData();
const file = formData.get('image');
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const result = await new Promise((resolve, reject) => {
cloudinary.uploader.upload_stream(
{ folder: 'my-app' },
(err, result) => err ? reject(err) : resolve(result)
).end(buffer);
});
return NextResponse.json({ url: result.secure_url });
}
Note: no Multer needed here. request.formData() is built into Next.js Route Handlers.
Vercel Blob: Their native storage solution, simpler API if you're already all-in on Vercel.
The Routes Manifest Error
If you see this during deployment:
ENOENT: no such file or directory, lstat '/vercel/path0/path0/.next/routes-manifest.json'
It means you have outputFileTracingRoot in your next.config.mjs pointing to a parent directory. Remove it entirely — Vercel handles this automatically and the option causes a double-path bug on their build servers.
Cold Starts Are Real But Manageable
Free tier Vercel functions can take 1-3 seconds on a cold start. For an API route that hasn't been called recently, that first request will be slow.
What helps:
- Keep your route handlers small. Don't import your entire codebase at the top of each file.
- Use dynamic imports for heavy libraries that aren't needed on every request.
- For anything latency-sensitive, the paid plan keeps functions warm.
For most portfolio projects and small client apps, cold starts are a non-issue in practice. Users rarely notice a 1-2s delay on first load.
The Deployment Workflow I Actually Use
# Local dev
npm run dev
# Always build locally before pushing — much faster to find errors here than in Vercel logs
npm run build
# Push — Vercel auto-deploys from main
git push origin main
I always run npm run build locally before pushing. Vercel build errors are harder to debug than local ones, and you'll save yourself the 2-minute wait to discover your import path is wrong.
If you're deploying a client project and want a staging environment, Vercel automatically creates a preview deployment for every branch. Push to a feature branch, share the preview URL with the client, merge when they approve. It's a clean workflow.
Hit a deployment issue I haven't covered here? Send me a message — I've probably seen it.