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', // Auto-sync schema in dev
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, // mongodb://... or mongodb+srv://...
connectOptions: { dbName: 'my-app' },
autoPluralization: true,
transactionOptions: false, // Set object to enable transactions (requires replica set)
}),
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, // file:./payload.db or libsql://your-db.turso.io
authToken: process.env.DATABASE_AUTH_TOKEN, // Turso only
},
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 }, // injected by Vercel
}),
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, // the Workers D1 database binding
}),
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:
// ❌ Breaks atomicity — new connection, separate transaction
await payload.create({ collection: 'audit', data, /* no req */ })
// ✅ Joins the parent transaction
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, // Apply to 'media' upload collection
// Or pass options per collection:
// media: { prefix: 'uploads/' },
},
bucket: process.env.S3_BUCKET,
config: {
endpoint: process.env.S3_ENDPOINT, // For R2: https://<account>.r2.cloudflarestorage.com
region: process.env.S3_REGION, // 'auto' for R2
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true, // Required for R2/MinIO
},
}),
],
})
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: { /* GCS client options — keyFilename or Application Default Credentials */ },
}),
],
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, // the Workers R2 bucket binding
}),
],
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:
// src/collections/Media.ts
export const Media: CollectionConfig = {
slug: 'media',
upload: {
// staticDir: undefined (default — files routed through the adapter)
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:createon 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
setupskill — initial DB adapter selection during scaffolding. - The
cli-recipesskill —migrate:create,migrate,migrate:down. - The
hooksskill — req threading inside hooks.
After changing a DB adapter, restart pnpm dev — Payload caches the schema at boot.