Admin / Media / Deploy — The Operations Layer
Goals for this chapter
- Find the customization points for the Admin UI (logo / Custom Components / theme)
- See the overall shape of the plugin ecosystem and the everyday plugins
- Write the basic pattern for swapping Media from local → S3
- Understand how to operate the Postgres adapter (db.push vs
payload migrate) - Know the minimal shape of background work and scheduled jobs with the Jobs Queue
- Develop a decision rule for Vercel / Docker / Railway / Fly deployments
- Glance at AI Auto-Embedding and close out the series
7.1 Admin UI customization
According to the official docs (Admin Overview), the Admin Panel is built in TypeScript + React and runs as part of the (payload) route group under the Next.js App Router. The admin property is the customization hub.
Main admin options
| Option | Role |
|---|---|
user | Which Collection authenticates ("admins", "users", …) |
components | UI overrides / injections |
livePreview | Real-time preview (chapter 06) |
autoLogin | Auto-login for development |
avatar | Profile picture display |
dateFormat | Date display format |
theme | Force theme ('light' | 'dark' | 'all') |
Plugging in Custom Components
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
user: 'users',
components: {
beforeNavLinks: ['./components/CustomNavBanner'],
afterDashboard: ['./components/DashboardWidget'],
graphics: {
Logo: './components/Logo',
Icon: './components/Icon',
},
},
},
// ...
})
You get hooks like beforeNavLinks / afterNavLinks / beforeDashboard / afterDashboard, and you can drop in either Server or Client components. graphics.Logo swaps the logo, and custom.scss overrides CSS — together these are enough for white-label branding.
Admin features added mid-3.x (overview)
The 3.x line has kept adding management features around the Admin Panel. The deep dives live in the official docs; knowing these exist helps with selection and operations decisions:
- Trash (soft delete) — with
trash: trueon a Collection, deletion becomes "moved to trash" with adeletedAttimestamp, restorable (or permanently deletable) from the Admin UI's Trash view (Trash) - Folders (beta) — organize documents into folders across Collections. Set
folders: trueon the Collection and configurefoldersat the config level (Folders; the docs note it's beta and may change in minor updates) - Query Presets — save and share list filters, columns, and sort orders with your team. Enable per Collection with
enableQueryPresets: true(Query Presets)
7.2 The plugin ecosystem
Per the official docs (Plugins Overview), plugins are added by listing them in config.plugins. The plugin signature is a pure function that takes the existing Config and returns a modified one:
type Plugin = (incomingConfig: Config) => Config
Major official plugins maintained by the Payload team
| Plugin | Use |
|---|---|
| Form Builder | Build forms in the Admin UI, serve as JSON to the frontend |
| SEO | Bulk-add OGP / meta-tag / sitemap fields |
| Search | Auto-build a search-index Collection |
| Nested Docs | Hierarchical Collections (page trees etc.) |
| Redirects | Manage redirect rules from the Admin UI |
| Stripe | Stripe integration (customer / subscription sync) |
| Multi-Tenant | Multi-tenancy (data isolation per tenant) |
| Sentry | Error reporting |
| Import / Export | Data import / export |
| MCP | Model Context Protocol integration |
| Ecommerce | E-commerce starter kit |
| Cloud Storage | Legacy generic storage plugin (for S3 specifically, prefer @payloadcms/storage-s3 — see next section) |
Usage example
import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { searchPlugin } from '@payloadcms/plugin-search'
export default buildConfig({
collections: [/* ... */],
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
generateTitle: ({ doc }) => `${doc.title} | My Site`,
}),
searchPlugin({
collections: ['posts'],
}),
],
})
Plugins run before the config is normalized, so they can add Collections, inject Hooks into existing Collections, and so on.
7.3 Media (Uploads) — local vs S3
Basics of an upload-enabled Collection
According to the official docs (Upload Overview), adding the upload property gives a Collection file-upload behavior, and filename / mimeType / filesize are added automatically.
import path from 'path'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: path.resolve(__dirname, '../media'),
mimeTypes: ['image/*'],
imageSizes: [
{ name: 'thumbnail', width: 400, height: 300, position: 'centre' },
{ name: 'card', width: 768, height: undefined }, // height auto
{ name: 'hero', width: 1920, height: 1080 },
],
adminThumbnail: 'thumbnail',
focalPoint: true,
},
fields: [
{ name: 'alt', type: 'text' },
],
}
imageSizes — automatic resizing
According to the official docs, the entries in imageSizes are generated automatically through the Sharp library. height: undefined keeps aspect ratio, position controls crop placement, and withoutEnlargement controls "what to do when the source is smaller than the target."
Switching to S3
The @payloadcms/storage-s3 plugin swaps storage over to S3 (and any S3-compatible bucket). This is the successor to the 2.x-era @payloadcms/plugin-cloud-storage.
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
collections: [Media],
plugins: [
s3Storage({
collections: {
media: true, // Use S3 for the Media collection
},
bucket: process.env.S3_BUCKET!,
config: {
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
},
clientUploads: true, // sidestep Vercel's 4.5MB body cap (see below)
}),
],
})
config accepts a full S3ClientConfig from @aws-sdk/client-s3, which means the same syntax works for Cloudflare R2 / DigitalOcean Spaces / Backblaze B2 and every other S3-compatible store.
Bucket-level design (Block Public Access, encryption, versioning, CORS, and the prerequisites for presigned URLs) is covered in Practical AWS Guide Chapter 5: S3.
Why clientUploads
Vercel's serverless functions cap request bodies at 4.5 MB, so uploading larger images through the Admin UI fails. With clientUploads: true, the browser uploads directly to S3 via a presigned URL, bypassing the server entirely. On Vercel, this is effectively required.
7.4 Picking a DB adapter
| Adapter | Package | Notes |
|---|---|---|
| Postgres | @payloadcms/db-postgres | Stable in 3.x / Drizzle ORM + node-postgres / the default in this series |
| Vercel Postgres | @payloadcms/db-vercel-postgres | Optimized for Vercel Postgres |
| MongoDB | @payloadcms/db-mongodb | The traditional choice since 1.x / flexible schemas |
| SQLite | @payloadcms/db-sqlite | Local development / small deployments / transactions are off by default |
If you're unsure: a real RDB in production → Postgres; lots of schema churn → MongoDB; keep it on one machine → SQLite.
7.5 Migration strategy
According to the official docs (Migrations), the Postgres and SQLite adapters borrow Drizzle ORM's migration machinery.
Commands
| Command | Use |
|---|---|
payload migrate | Apply outstanding migrations in order |
payload migrate:create | Generate a new migration file (from the current schema diff) |
payload migrate:status | Check what's applied |
payload migrate:down | Roll back the last batch |
payload migrate:refresh | Roll back everything, then re-apply |
payload migrate:reset | Roll back every migration |
payload migrate:fresh | Reset the DB completely and rebuild |
Migration file shape
// migrations/20260616_add_posts_status.ts
import type { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres'
export async function up({ payload, req }: MigrateUpArgs): Promise<void> {
// schema change
}
export async function down({ payload, req }: MigrateDownArgs): Promise<void> {
// revert
}
db push (dev) vs payload migrate (prod)
| Mode | Where | Purpose |
|---|---|---|
db.push: true (default in dev) | Local DB | Push schema changes instantly, no migration files |
payload migrate | Production DB | Run in CI/CD, migration files required |
The official docs are explicit: never mix push and migrate on the same local DB. The standard flow is to iterate fast with push locally, then migrate:create to freeze a migration before release.
Typical CI flow
// package.json
{
"scripts": {
"build": "next build",
"ci": "payload migrate && pnpm build"
}
}
Running payload migrate before every deploy keeps your production DB in lockstep with the code.
7.6 Jobs Queue (background work)
For work that belongs outside the request — sending email, calling external APIs, heavy aggregation — PayloadCMS ships a Jobs Queue as a core feature (Jobs Queue (official docs)). There's no separate job infrastructure to stand up: task definitions through scheduled execution all live in the config.
Defining tasks and queueing
Define slug / inputSchema / handler under jobs.tasks, then enqueue with payload.jobs.queue():
// payload.config.ts
export default buildConfig({
// ...
jobs: {
tasks: [
{
slug: 'sendEmail',
inputSchema: [
{ name: 'userId', type: 'text', required: true },
],
handler: async ({ input, req }) => {
const user = await req.payload.findByID({
collection: 'users',
id: input.userId,
})
await req.payload.sendEmail({
to: user.email,
subject: 'Welcome!',
html: '...',
})
return { output: {} }
},
},
],
},
})
// Enqueue from a Server Action / Hook / custom endpoint
await payload.jobs.queue({
task: 'sendEmail',
input: { userId: '123' },
waitUntil: new Date('2026-12-25T00:00:00Z'), // (optional) delay execution
})
Cron schedules and autoRun
Add schedule to a task definition to enqueue it on a cron cadence, and let an autoRun runner process the queue:
jobs: {
tasks: [
{
slug: 'dailyDigest',
schedule: [{ cron: '0 8 * * *', queue: 'daily' }], // enqueue daily at 8:00
handler: async ({ req }) => {
/* ... */
return { output: {} }
},
},
],
autoRun: [
{ cron: '* * * * *', queue: 'daily', limit: 10 }, // check the queue every minute
],
}
schedule (the enqueuer) and autoRun (the runner) must point at the same queue name.
The serverless (Vercel) caveat
The official docs are explicit: "autoRun is intended for use with a dedicated server that is always running, and should not be used on serverless platforms like Vercel" (Queues (official docs)). On serverless, hit the jobs endpoint GET /api/payload-jobs/run from an external scheduler instead. On Vercel, register that path under crons in vercel.json and set the CRON_SECRET environment variable — Vercel then sends Authorization: Bearer ${CRON_SECRET} automatically, protecting the endpoint. On an always-on Node server (Docker / VPS, section 7.7), autoRun works as-is.
Workflows (chaining tasks) and retry controls are covered in the official docs. For this series, knowing the Jobs Queue exists in core is enough.
7.7 Deployment — four options
PayloadCMS basically runs anywhere Node.js runs (Edge is out, see chapter 06). The main paths:
Vercel
The fastest path. Vercel Postgres + @payloadcms/db-vercel-postgres is the canonical pairing.
Things to watch for:
- Function timeout: with fluid compute (on by default), Vercel Functions default to 300 seconds (5 minutes). The ceiling is 300 seconds on Hobby and 800 seconds on Pro / Enterprise, or up to 1,800 seconds (30 minutes) with the beta extended max duration (Vercel: Configuring Maximum Duration, as of 2026-07). Bulk imports and migrations need care
- 4.5 MB body cap: large Admin UI uploads fail;
@payloadcms/storage-s3withclientUploads: truesolves it (section 7.3) - Cold start: function-based, so the first request after an idle period is slow
// vercel.json
{
"functions": {
"app/(payload)/api/**/route.ts": { "maxDuration": 60 }
}
}
Docker / your own Node server
For heavy-editor workflows or long-running jobs, a Node server on a VPS fits better. No cold start, no function timeout, no body cap.
The official Docker example (from node:24-alpine, multi-stage, output: 'standalone'):
# Excerpt
FROM node:24-alpine AS base
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
CMD ["node", "server.js"]
Don't forget output: 'standalone' in next.config.
Railway / Fly / Render
"Managed container" hosts. They build and bring up your container — somewhere between Docker and Vercel: easier cost story than Vercel, no timeout caps. The official line is that Payload runs anywhere Next.js runs.
Cloudflare Workers (D1 + R2)
One-click Cloudflare Workers deployment shipped on October 3, 2025 (official blog post). The official template with-cloudflare-d1 sets you up with Workers + D1 (DB) + R2 (Storage).
Constraints:
- The Workers Paid plan is required (the Free plan's 3MB Workers size limit is too small)
- Pino-pretty depends on Node APIs, so the template replaces it with a custom console logger
- GraphQL currently has issues on Workers (upstream — see the template's docs)
- The legacy Cloudflare Pages is not the target; deployment goes through Workers
The underlying constraint ("PayloadCMS doesn't run on the Edge runtime", chapter 06 section 6.5) still holds — on Cloudflare you're using a Node-compatible runtime through Workers. This series treats Cloudflare as a viable option when the constraints match your requirements.
7.8 Payload Cloud (closed to new sign-ups)
Since the Figma acquisition in June 2025, Payload Cloud has stopped accepting new sign-ups. Existing customers retain access, but new projects need to self-host using the four options above (source: Payload is joining Figma! (official blog) / GitHub Discussion #12843).
Given that OSS / self-host is the core of PayloadCMS anyway, not depending on Payload Cloud is the safer long-term call.
7.9 AI Auto-Embedding (sketch)
The official docs top page (What is Payload) lists AI Auto-Embedding as an enterprise feature. The idea: have Payload auto-generate vector embeddings from your Collection content for use as a RAG (Retrieval-Augmented Generation) data source.
If you want to build RAG on the OSS core, you can do it by hand:
- A Hook (afterChange) extracts the text
- Call an embedding API (OpenAI / Cohere / Voyage, …)
- Store in a vector DB (pgvector / Pinecone / Weaviate, …)
- Expose a search endpoint via
endpoints
The enterprise version takes a lot of the wiring off your hands.
7.10 Environment variable cheat sheet
Minimum set for running PayloadCMS in production:
# Payload itself
PAYLOAD_SECRET=a long random string for production (32+ chars recommended)
DATABASE_URL=postgresql://user:pass@host:5432/db
PAYLOAD_PUBLIC_SERVER_URL=https://example.com
# Preview
PREVIEW_SECRET=a separate token for draft preview
# Media (if using S3)
S3_BUCKET=my-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
# Next.js frontend
NEXT_PUBLIC_SERVER_URL=https://example.com
PAYLOAD_SECRET should be long and random. PAYLOAD_CONFIG_PATH is only needed when the config sits in a non-standard location.
Implementation example — Postgres migration with raw SQL (up / down)
payload migrate:create scaffolds a migration file that exports up and down functions. With the Postgres adapter you can pass an sql template literal to db.execute() to run raw SQL (Migrations doc).
// migrations/20260616_100000_add_archived_flag.ts
import { type MigrateUpArgs, type MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// Schema changes typically flow through `payload generate:db-schema` + `migrate:create`,
// but raw SQL is the right tool for data backfills or custom indexes.
await db.execute(sql`
ALTER TABLE posts
ADD COLUMN archived BOOLEAN NOT NULL DEFAULT false
`)
// Touch existing data — the official Postgres example uses `SELECT * from posts`
const { rows: posts } = await db.execute(sql`SELECT id FROM posts WHERE _status = 'published'`)
payload.logger.info({ migrated: posts.length }, 'archived flag initialized')
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE posts
DROP COLUMN archived
`)
}
Notes:
import { sql } from '@payloadcms/db-postgres'— adapter-specific (SQLite users import from@payloadcms/db-sqlite)db.execute()receives thesqltemplate literal and runs the SQL; the result is{ rows: T[] }- The
downfunction invertsup— make rollbacks possible in production payload.loggeris the same pino-style API you saw in chapter 04 (audit logs)
The official docs focus on raw SQL examples like the one above. A full example using Drizzle's query builder inside a migration file isn't documented. If you want the type-safe path, read Drizzle's own docs alongside.
Source: Migrations (official docs)
Wrap-up
- Customize the Admin UI via
admin.components, rebrand withgraphics.Logoandcustom.scss - Plugins are just an array under
config.plugins; the team ships SEO / Form Builder / Search / Stripe / Multi-Tenant / Sentry and more - Move Media to S3 with one line of
s3Storage(); on Vercel,clientUploads: trueis effectively mandatory - Postgres adapter: never mix dev push with
payload migrate, and always run migrate in CI - Pick deployment from Vercel / Docker / Railway-style / a bare Node server for the use case. Edge runtime is essentially out; Cloudflare works via Workers (Paid plan, with constraints)
- The Jobs Queue is core: task definitions +
payload.jobs.queue()+schedule/autoRun. On serverless, skip autoRun and hit/api/payload-jobs/runfrom an external cron - Mid-3.x Admin additions: Trash / Folders (beta) / Query Presets — reach for the official docs when you need them
- Payload Cloud stopped accepting new sign-ups in June 2025; self-host is the default
- AI Auto-Embedding is enterprise; on OSS you can roll your own with a Hook plus a vector DB
- Migrations support raw SQL:
import { sql } from '@payloadcms/db-postgres'and pass thesqltemplate literal todb.execute(), with symmetricup/downfunctions
Series wrap-up
Across seven chapters we've walked the path of "co-locate PayloadCMS in a Next.js project and write everything from data model through operations in one TypeScript codebase":
- 01 What is PayloadCMS / Why now
- 02 Core Concepts
- 03 Data Modeling
- 04 Security & Lifecycle
- 05 APIs
- 06 Next.js Integration
- 07 Admin / Media / Deploy (this chapter)
The next step is to run create-payload-app on your own project and start with a single minimal Collection. Reading the official docs page by page alongside the mental model you've built here is the fastest way to make it stick.
References
- Admin Overview (official docs)
- Plugins Overview (official docs)
- Upload Overview (official docs)
- Postgres Adapter (official docs)
- Migrations (official docs)
- Production Deployment (official docs)
@payloadcms/storage-s3(npm)- Vercel: Configuring Maximum Duration
- Payload is joining Figma! (official blog) (on Payload Cloud closed to new sign-ups)