← Back to Blog
Full-Stack24 May 2026·6 min read

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.

MERNVercelMongoDBNext.jsDeployment

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.

R
Md Refat Bhuyan
Full-Stack Developer & AI Engineer · Available for hire
Deploying a Full-Stack MERN App on Vercel — The Things Nobody Tells You | Refat Bhuyan