The problem
Built on the Payload website template, which auto-syncs the database to the code in development — so the running schema quietly drifts from what is actually committed, and a deploy is the first place you find out.
The decision
Migrations are the source of truth. `db.push` is false in every environment, dev included, so each schema change is an explicit generated migration that is committed and applied before deploy — and Vercel never migrates on its own.
This site is the third iteration of my personal portfolio, and unlike the previous two, it's built on Payload CMS running as a Next.js 15 native app rather than a headless API bolted onto a separate frontend. The whole thing runs on Vercel Hobby with Supabase Postgres in Tokyo as the database, Supabase Storage (S3-compatible) for media, and Resend for email. The architecture is boring on purpose — every piece has a clear job and no piece is doing double duty.
I wanted a CMS I could actually extend with TypeScript without fighting an abstraction layer. Payload gives you collections, globals, hooks, and access control all in one config file. The tradeoff is that it's opinionated about Next.js, but that's fine — Next.js is where I was going anyway.
The Stack
- Payload CMS 3 (Next.js 15 native) — content model, admin UI, REST and GraphQL APIs
- Supabase Postgres (ap-northeast-1 / Tokyo) — primary datastore
- Supabase Storage via S3-compatible API — media files, no egress cost surprises
- Resend — transactional email via the official Payload adapter
- Vercel Hobby — deployment, with edge functions pinned to hnd1 to stay close to the DB
Design Decisions Worth Explaining
Two settings in the config carry more weight than they look like they do. The first is push: false on the Postgres adapter. By default, Payload in development mode will auto-push schema changes to the database the moment you modify a collection. That means running pnpm dev on a new machine could silently diverge your live schema from the migration files checked into source control. Turning it off forces every schema change through a real migration, which is the only sane behavior for anything you're going to deploy.
db: postgresAdapter({// Migrations are the source of truth — disable dev auto-push so// running `pnpm dev` can't silently diverge the DB from `src/migrations/`.push: false,pool: {connectionString: process.env.DATABASE_URL || '',max: 10,idleTimeoutMillis: 30_000,},}),// In plugins/index.ts:s3Storage({enabled: process.env.NODE_ENV === 'production',collections: {media: {// Public bucket — hand out direct Supabase Storage URLs instead of// proxying every image through Payload's /api/media/file endpoint.disablePayloadAccessControl: true,},},bucket: process.env.S3_BUCKET || '',config: {endpoint: process.env.S3_ENDPOINT,region: process.env.S3_REGION,credentials: {accessKeyId: process.env.S3_ACCESS_KEY_ID || '',secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',},forcePathStyle: true,},})
The second is disablePayloadAccessControl: true on the media collection. Payload's default behavior is to proxy every file request through its own /api/media/file endpoint, where it checks access control before serving the bytes. For a public portfolio with no private images, that's an extra cold-start-prone serverless function invocation on every image load. Setting this flag tells Payload to hand out the raw Supabase Storage URL instead. Images are served directly from Supabase's CDN with no Payload involvement, and the pool of 10 connections stays available for actual database work.
Note
The s3Storage plugin is only enabled in production. In development, Payload falls back to local disk storage, so you can work offline and avoid polluting the live bucket with test uploads.
What Was Harder Than Expected
Getting Vercel function region pinning right took longer than it should have. Vercel Hobby doesn't surface region config in the dashboard — you set it via a routes config in vercel.json and then pray the deployment picks it up. The feedback loop is slow because you can only confirm it worked after a full deploy. Once hnd1 was locked in, Postgres query latency dropped from 80-120ms to 8-15ms, which is the difference between a site that feels snappy and one that feels like it's thinking.
The other rough edge was the migration workflow itself. Payload generates migrations by diffing the current schema state against the last migration file, but if you run the generator while the database is ahead or behind, it produces a no-op or a broken migration. The fix is always the same — reset to a clean slate, run payload migrate, check the output — but it cost me an hour the first time I hit it. The lesson is to treat payload migrate:create like a database commit: only run it when your working tree is clean and the DB is at the correct baseline.