| name | adapters |
| description | This skill should be used when the user asks about "Payload database adapter", "Postgres in Payload", "MongoDB Payload setup", "SQLite Payload", "S3 storage adapter", "Cloudflare R2 Payload", "Vercel Blob upload", "Payload email Resend", "Payload Nodemailer SMTP", "Payload transactions", or needs to wire Payload to a database, file storage, or email provider. |
PayloadCMS — Adapters
Payload is database-agnostic, storage-agnostic, and email-agnostic. Pick adapters per environment in payload.config.ts. This skill covers the three adapter families and the transaction model that ties them together.
Database Adapters
Set in payload.config.ts under db. Only one per app.
PostgreSQL (recommended for production)
pnpm add @payloadcms/db-postgres
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URI },
push: process.env.NODE_ENV !== 'production',
migrationDir: path.resolve(dirname, 'migrations'),
schemaName: 'public',
transactionOptions: { isolationLevel: 'read committed' },
}),
})
Connection string forms:
postgres://USER:PASS@HOST:5432/DBNAME
postgresql://USER:PASS@HOST:5432/DBNAME?sslmode=require
push: true syncs the schema automatically (great for dev). For production, disable push and use migrations — see the cli-recipes skill.
MongoDB
pnpm add @payloadcms/db-mongodb
import { mongooseAdapter } from '@payloadcms/db-mongodb'
db: mongooseAdapter({
url: process.env.DATABASE_URI,
connectOptions: { dbName: 'my-app' },
autoPluralization: true,
transactionOptions: false,
}),
MongoDB transactions require a replica set — single-node mongod won't work. For Atlas, replica set is enabled by default. For local dev, run mongod --replSet rs0 then rs.initiate() once.
SQLite (libSQL / Turso)
pnpm add @payloadcms/db-sqlite
import { sqliteAdapter } from '@payloadcms/db-sqlite'
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI,
authToken: process.env.DATABASE_AUTH_TOKEN,
},
push: process.env.NODE_ENV !== 'production',
transactionOptions: { behavior: 'immediate' },
}),
Vercel Postgres
pnpm add @payloadcms/db-vercel-postgres
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
db: vercelPostgresAdapter({
pool: { connectionString: process.env.POSTGRES_URL },
}),
Wraps @vercel/postgres. Same migration story as postgresAdapter.
Cloudflare D1
pnpm add @payloadcms/db-d1-sqlite
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'
db: sqliteD1Adapter({
binding: env.D1,
}),
For running Payload on Cloudflare Workers with a D1 database — see the official with-cloudflare-d1 template (pairs with the r2Storage upload adapter below).
Transactions
Postgres, SQLite (with transactionOptions), and MongoDB-with-replica-set provide all-or-nothing transactions. Payload uses them automatically per HTTP request. You must thread req through nested ops to keep the transaction alive:
await payload.create({ collection: 'audit', data, })
await payload.create({ collection: 'audit', data, req })
When to pass req:
- Inside any hook (collection, field, global).
- Inside a custom endpoint that mutates multiple collections.
- In jobs/workflows where you want atomic multi-step writes.
When req is optional:
- Read-only top-level ops that don't depend on uncommitted writes.
- Migration scripts (each migration commits independently).
See references/transactions.md for the deep dive.
Storage Adapters
Default behavior in upload collections: files written to staticDir on disk. That doesn't survive deploys on Vercel/Render/Fly and doesn't scale. Use a storage adapter in production.
AWS S3 / S3-Compatible (R2, Backblaze B2, MinIO)
pnpm add @payloadcms/storage-s3
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
plugins: [
s3Storage({
collections: {
media: 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,
},
}),
],
})
Cloudflare R2: endpoint is https://<accountId>.r2.cloudflarestorage.com, region: 'auto'.
Vercel Blob
pnpm add @payloadcms/storage-vercel-blob
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
plugins: [
vercelBlobStorage({
collections: { media: true },
token: process.env.BLOB_READ_WRITE_TOKEN,
addRandomSuffix: true,
}),
],
UploadThing
pnpm add @payloadcms/storage-uploadthing
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
plugins: [
uploadthingStorage({
collections: { media: true },
options: {
apiKey: process.env.UPLOADTHING_SECRET,
acl: 'public-read',
},
}),
],
Azure Blob
pnpm add @payloadcms/storage-azure
import { azureStorage } from '@payloadcms/storage-azure'
plugins: [
azureStorage({
collections: { media: true },
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
containerName: process.env.AZURE_STORAGE_CONTAINER,
}),
],
Since 3.87.0, client uploads support chunkLargeFiles for files larger than 5GB.
Google Cloud Storage
Official adapter:
pnpm add @payloadcms/storage-gcs
import { gcsStorage } from '@payloadcms/storage-gcs'
plugins: [
gcsStorage({
collections: { media: true },
bucket: process.env.GCS_BUCKET,
options: { },
}),
],
Cloudflare R2 (dedicated adapter — Workers)
@payloadcms/storage-r2 exports r2Storage() for deployments on Cloudflare Workers with a native R2 bucket binding:
pnpm add @payloadcms/storage-r2
import { r2Storage } from '@payloadcms/storage-r2'
plugins: [
r2Storage({
collections: { media: true },
bucket: env.R2_BUCKET,
}),
],
On Node hosts (Vercel/Netlify/self-host), R2 via s3Storage with the S3-compatible config above remains the recommended approach.
After enabling a storage adapter — you'll usually drop upload.staticDir from the collection because the files live in the bucket, not on disk:
export const Media: CollectionConfig = {
slug: 'media',
upload: {
mimeTypes: ['image/*', 'application/pdf'],
imageSizes: [],
},
fields: [{ name: 'alt', type: 'text', required: true }],
}
The plugin auto-routes doc.url to the bucket's public URL (or signed URL if private).
Email Adapters
Default: emails are logged to the console. Wire a real provider for forgotPassword, verify, and your own outgoing mail.
Resend (transactional, simplest)
pnpm add @payloadcms/email-resend
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'no-reply@example.com',
defaultFromName: 'My App',
apiKey: process.env.RESEND_API_KEY,
}),
})
Nodemailer (SMTP — any provider with SMTP creds)
pnpm add @payloadcms/email-nodemailer
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
email: nodemailerAdapter({
defaultFromAddress: 'no-reply@example.com',
defaultFromName: 'My App',
transportOptions: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
Send mail programmatically:
await payload.sendEmail({
to: user.email,
subject: 'Welcome',
html: '<p>Hi!</p>',
})
Switching Adapters
When swapping databases (e.g., SQLite dev → Postgres prod):
- Run
payload migrate:create on Postgres to generate a fresh migration set.
- Export data with a one-off script using the Local API on the old adapter, then import on the new one.
- Don't try to run the same migration files across DB types — adapter-specific.
See Also
references/transactions.md — full transaction semantics, isolation levels, gotchas.
- The
setup skill — initial DB adapter selection during scaffolding.
- The
cli-recipes skill — migrate:create, migrate, migrate:down.
- The
hooks skill — req threading inside hooks.
After changing a DB adapter, restart pnpm dev — Payload caches the schema at boot.