← All writing
deployment·5 min read

Deploying Next.js on Vercel without the surprises

Connecting the repository is the easy part. These are the failures a full-stack Next.js app actually hits on Vercel — generated Prisma clients, build-time database connections, migrations, and image hosts — and what each one really means.

Irfan Ali

Software Engineer

Deploying a Next.js app to Vercel is famously a two-click affair: connect the repository, press Deploy. That is true right up until your app has a database, uploads files, or needs migrations — and then you meet a series of failures that all look like Vercel being broken and are all actually your build being underspecified.

These are the ones I hit shipping a full-stack Next.js 16 app with Postgres, Prisma and Blob storage, and what each one actually meant.

Connect the repository

Push to GitHub, then in Vercel: Add New → Project → Import. Framework detection should say Next.js on its own. If it says "Other", the app is not at the repository root — set Root Directory rather than fighting the build command.

Do not deploy yet. Set the environment variables first, because the first build will read them.

Environment variables

Settings → Environment Variables. The trap here is that Vercel distinguishes between build time and run time, and a variable that is missing during the build fails differently from one missing at runtime.

For a Prisma + Postgres app the minimum is:

bash
DATABASE_URL="postgresql://user:pass@host-pooler.region.aws.neon.tech/db?sslmode=require"
AUTH_SECRET="…"   # openssl rand -base64 32

Two things about that connection string.

Use the pooled one. Serverless functions open a connection per invocation. A direct connection string will exhaust the database's connection limit under any real traffic — Neon's pooled host has -pooler in the hostname, Supabase gives you a separate pooler port.

Mark secrets as Sensitive. Vercel then stores them write-only. This is correct, and it has a consequence people discover at the worst moment: vercel env pull returns [SENSITIVE] instead of the value, so you cannot pull production credentials to your laptop to run a migration. Plan for migrations to run somewhere that already has the variable.

The Prisma client is not in your repository

The first failure most people hit:

text
Type error: Module '"@prisma/client"' has no exported member 'PrismaClient'.

This is confusing because it works locally. It works locally because you ran prisma generate at some point, and the generated client landed in node_modules — which is gitignored. Vercel checks out your repository, runs npm install, and gets the unpopulated package.

Generate it as part of the build:

json
{
  "scripts": {
    "build": "prisma generate && next build",
    "postinstall": "prisma generate"
  }
}

postinstall covers the install step; keeping it in build too means the build is correct even if the install is served from cache.

Do not connect to the database while building

The next failure is subtler. This looks harmless:

ts
export const db = new PrismaClient({
  adapter: new PrismaPg(process.env.DATABASE_URL!),
});

Next imports your modules while collecting page data during the build. If that module connects — or throws because DATABASE_URL is not set yet — the build fails rather than the request. Initialise on first use instead:

ts
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const db = new Proxy({} as PrismaClient, {
  get(_target, prop, receiver) {
    const client = (globalForPrisma.prisma ??= createClient());
    return Reflect.get(client, prop, receiver);
  },
});

The proxy defers construction until something actually reads a property off db. Caching on globalThis also keeps hot reloads in development from opening a new connection every time you save.

Running migrations

Your database starts empty. The application deploys perfectly and then every page 500s, or — worse — logs you out with a message about bad credentials, because the users table does not exist yet.

If you can read DATABASE_URL locally, the simplest thing is to run it from your machine:

bash
npx prisma migrate deploy

Note migrate deploy, not migrate dev. The deploy variant applies existing migrations and never tries to generate new ones or reset anything.

If the variable is marked Sensitive and you cannot pull it, run migrations in the build instead. Vercel prefers a vercel-build script when one exists, which keeps this out of your local build:

json
{
  "scripts": {
    "build": "prisma generate && next build",
    "vercel-build": "prisma generate && prisma migrate deploy && next build"
  }
}

Be deliberate about this. Migrations in the build run on every deploy, including rollbacks and preview branches. It suits a small app with one database; it does not suit a team with several environments sharing one connection string.

Uploads and next/image

If you store uploads on Vercel Blob, next/image will reject them until you say the host is allowed:

ts
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "*.public.blob.vercel-storage.com", pathname: "/**" },
    ],
  },
};

Without it you get Invalid src prop … hostname is not configured. The reason this one bites hard is that it does not fail at build time or on any page you have already looked at. It fails the first time a real uploaded image renders — which may well be in production, on the page you were proud of.

A checklist that would have saved me a few builds

SymptomCause
has no exported member 'PrismaClient'Client never generated on the build machine
Build fails collecting page dataSomething connects to the database at import time
Pages 500 immediately after a clean deployMigrations never ran against the production database
Login rejects correct credentialsDatabase reachable but empty — no user row
Invalid src propUpload host missing from images.remotePatterns
Connection limit errors under loadDirect connection string instead of the pooled one

The part worth internalising

Nearly all of these come from the same mistake: treating your laptop's state as the truth. Your machine has a generated client, a populated database, a .env with every variable, and a photo already in /public. The build machine has none of that — it has your repository, your package.json scripts, and the environment variables you remembered to set.

The fix in every case is to make the build say out loud what it needs. Once it does, deployment stops being a source of surprises and goes back to being the two-click affair it was advertised as.