In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I'd moved my Payload CMS migrations to run after the production build on Vercel, not before. The reasoning felt sound at the time: if a build fails, I didn't want to have already mutated the database. Preview deployments got the same treatment — no migrations at all, so a PR branch could never touch production data. It shipped, it looked correct, and then the very next production deploy failed outright.
The fix ended up being more interesting than "just move migrate back before build." Here's the full path, including the two infrastructure gotchas that turned a one-line revert into three separate deploys.
Why the build itself needed the migration first
The app is Next.js 16 with Cache Components turned on:
That single flag changes what next build actually does. With Cache Components, static generation runs against live data at build time — for this app, that means next build opens a connection to the production Postgres database and runs real queries while it's compiling pages. It's not deferred to request time; it happens during the build step itself, in the same process that's producing the .next output.
That's the detail that broke everything. I'd shipped a Payload CMS field addition — a new articleSource option on a Featured Articles block — alongside a migration that adds the backing column. The code for that field was already in the deploy; the column it needed wasn't, because I'd rescheduled payload migrate to run after the build instead of before it. next build tried to statically generate a page that queried the new column, and Postgres had never heard of it:
code
error: column page__blocks_featured_articles_b.article_source does not exist
The build never got anywhere near the migrate step. It died mid-compile, on a query the new code needed but the old schema couldn't answer.
Putting migrate back first, without giving up the safety net
The obvious fix is to run payload migrate before next build again. The part worth keeping from the broken version was scoping it to production only, so Preview builds never touch the database — that part of the original change was genuinely correct, just packaged with the ordering mistake.
What I actually wanted was both properties at once: migrate before build, so the build's own queries succeed, and never leave the database migrated behind a build that didn't ship. Payload's CLI makes the second half straightforward — payload migrate --help lists a migrate:down command, and I checked what it actually does before relying on it:
migrate:down rolls back the entire latest batch — every migration that was applied in that run — by calling each migration file's own down() function inside a transaction. That's exactly the primitive needed: migrate first, and if the build fails afterward for any reason, undo precisely what was just applied.
On production, migrate runs first against the direct (unpooled) Neon connection, so the schema is in place before next build starts reading from it. If the build then fails for any reason at all — the migration query, an unrelated compile error, doesn't matter — the script rolls that batch back before exiting non-zero. Preview builds skip both the migrate and the rollback entirely, since VERCEL_ENV is never "production" there.
Worth being honest about the limit here: this protects an additive migration paired with an unrelated build failure. If a migration ever drops or renames a column that the currently live deployment still reads, there's still a window — between migrate succeeding and the build finishing — where the old code is running against a schema it doesn't expect. Rollback shortens that window, it doesn't remove it. Destructive schema changes still need an expand/contract release regardless of build ordering. The migration that triggered this whole investigation only added nullable columns and a foreign key, so it was safe to run ahead of the build — but that's a property of the migration, not of the script.
Gotcha one: vercel.json has a 256-character limit on buildCommand
My first attempt put that whole script inline as a one-liner in vercel.json, chained with && and if/fi. It deployed, and immediately showed Error status with a two-minute-old timestamp and nothing else useful in the dashboard.
vercel inspect <url> --logs came back with zero log lines — not truncated, empty. That was the actual signal: an empty log means the deployment never reached the build step at all. Vercel's config validation was rejecting the deployment before it started. Pulling the deployment record directly from the API confirmed it:
javascript
// checked via GET https://api.vercel.com/v13/deployments/:iderrorMessage: "The `vercel.json` schema validation failed with the following message: `buildCommand` should NOT be longer than 256 characters"
The inline script was 407 characters. The fix was to stop putting logic inside vercel.json entirely — buildCommand now just calls out to the script file:
Eighteen characters, well under the limit, and the actual logic lives in a real file that can have comments and be tested locally with sh -n.
Gotcha two: .vercelignore silently drops the script it's supposed to run
The natural place to put a build script felt like scripts/, next to the project's other one-off utilities. That deployed cleanly this time — no config error — and then failed differently:
javascript
// checked via GET https://api.vercel.com/v13/deployments/:iderrorMessage: "Command \"sh scripts/vercel-build.sh\" exited with 127"errorCode: "ENOENT"
Exit 127 with ENOENT means the shell couldn't find the file. The build step had started this time, which ruled out another schema issue — so I checked what actually gets uploaded to Vercel:
code
# File: .vercelignore
# development scripts and data
scripts
seeds
transcripts
The entire scripts/ directory was excluded from the deployment upload, on purpose, for a completely unrelated reason — keeping one-off data migration and toolkit scripts out of the deployed bundle. vercel-build.sh inherited that exclusion just by sitting in the wrong directory. Moving it to the repo root, outside anything .vercelignore touches, was the actual fix:
The migration ran, the schema was in place, and next build's SSG queries against the new column succeeded. Deployment status: READY.
The underlying lesson wasn't really about migration ordering — it was that Cache Components makes next build a consumer of live production state, not just a compiler. Any build step reasoning that assumes next build is schema-agnostic breaks the moment it's turned on. And when a Vercel deployment fails with no useful detail in the dashboard, the deployment's raw errorMessage over the API is worth pulling before assuming the failure is anywhere near your own application code.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks,
Matija
await
getMigrations
// ...
const
filter
({ batch }) =>
for
const
of
// runs migrationFile.down(...) inside a transaction, then
// deletes the row from payload-migrations
# File: vercel-build.sh
#!/bin/sh
set
if
"$VERCEL_ENV"
"production"
then
"$DATABASE_URL_UNPOOLED"
exit
fi
set
set
if
"$BUILD_EXIT"
"$VERCEL_ENV"
"production"
then
echo
"Build failed after migration — rolling back with payload migrate:down"