diff --git a/docs/email/overview.mdx b/docs/email/overview.mdx
index 5dff8bcf458..cde743128cd 100644
--- a/docs/email/overview.mdx
+++ b/docs/email/overview.mdx
@@ -158,6 +158,77 @@ const email = await payload.sendEmail({
})
```
+## Sending email with attachments
+
+**Nodemailer adapter (SMTP/SendGrid/etc.)**
+
+Works with `@payloadcms/email-nodemailer` and any Nodemailer transport.
+
+```ts
+await payload.sendEmail({
+ to: 'user@example.com',
+ subject: 'Your report',
+ html: '
See attached.
',
+ attachments: [
+ // From a file path (local disk, mounted volume, etc.)
+ {
+ filename: 'invoice.pdf',
+ path: '/var/data/invoice.pdf',
+ contentType: 'application/pdf',
+ },
+ // From a Buffer you generated at runtime
+ {
+ filename: 'report.csv',
+ content: Buffer.from('col1,col2\nA,B\n'),
+ contentType: 'text/csv',
+ },
+ ],
+})
+```
+
+Anything supported by Nodemailer’s attachments—streams, Buffers, URLs, content IDs for inline images (cid), etc.—will work here.
+
+**Resend adapter**
+
+Works with @payloadcms/email-resend.
+
+For attachments from remote URLs
+
+```ts
+await payload.sendEmail({
+ to: 'user@example.com',
+ subject: 'Your invoice',
+ html: 'Thanks! Invoice attached.
',
+ attachments: [
+ {
+ // Resend will fetch this URL
+ path: 'https://example.com/invoices/1234.pdf',
+ filename: 'invoice-1234.pdf',
+ },
+ ],
+})
+```
+
+For a local file
+
+```ts
+import { readFile } from 'node:fs/promises'
+
+const pdf = await readFile('/var/data/invoice.pdf')
+await payload.sendEmail({
+ to: 'user@example.com',
+ subject: 'Your invoice',
+ html: 'Thanks! Invoice attached.
',
+ attachments: [
+ {
+ filename: 'invoice.pdf',
+ // Resend expects Base64 here
+ content: pdf.toString('base64'),
+ },
+ ],
+})
+```
+
## Using multiple mail providers
Payload supports the use of a single transporter of email, but there is nothing stopping you from having more. Consider a use case where sending bulk email is handled differently than transactional email and could be done using a [hook](/docs/hooks/overview).
diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx
index b2fbfca3e7e..02be919e134 100644
--- a/docs/fields/select.mdx
+++ b/docs/fields/select.mdx
@@ -68,6 +68,47 @@ _\* An asterisk denotes that a property is required._
used as a GraphQL enum.
+### Limitations for Arrays / Nested Fields (especially on MongoDB)
+
+
+ Avoid unique: true on fields nested inside an array or blocks.
+
+In MongoDB this creates a collection-wide unique multikey index; missing values
+are treated like null and will collide, causing duplicate-key errors on
+insert/update. If you need uniqueness within a parent document’s array (or
+conditional uniqueness), use a custom validate function or a hook.
+
+If you need collection-wide uniqueness for values that currently live in an array, consider
+normalizing those values into a top-level field or a separate collection where a
+standard unique index makes sense.
+
+Example:
+
+```ts
+import type { Field } from 'payload'
+
+export const ItemsArray: Field = {
+ name: 'items',
+ type: 'array',
+ fields: [
+ {
+ name: 'code',
+ type: 'text',
+ // DO NOT use unique: true here; see note above
+ validate: async (value, { data }) => {
+ // value is the current 'code'; data.items is the full array
+ if (!value || !Array.isArray(data?.items)) return true
+ const codes = data.items.map((i) => i?.code ?? '').filter(Boolean)
+ const duplicates = new Set(
+ codes.filter((c, i) => codes.indexOf(c) !== i),
+ )
+ return duplicates.size === 0 || 'Codes in this array must be unique.'
+ },
+ },
+ ],
+}
+```
+
### filterOptions
Used to dynamically filter which options are available based on the current user, document data, or other criteria.
diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx
index 1865133eced..ac2f06e4dd4 100644
--- a/docs/production/deployment.mdx
+++ b/docs/production/deployment.mdx
@@ -154,10 +154,10 @@ const nextConfig = {
Dockerfile
```dockerfile
-# Dockerfile
+# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.js file.
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
-FROM node:18-alpine AS base
+FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
@@ -204,6 +204,7 @@ ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
+# Remove this line if you do not have this folder
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
@@ -235,7 +236,7 @@ version: '3'
services:
payload:
- image: node:18-alpine
+ image: node:22-alpine
ports:
- '3000:3000'
volumes:
diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx
index 1167c957bf2..bb954ae57e4 100644
--- a/docs/upload/overview.mdx
+++ b/docs/upload/overview.mdx
@@ -305,6 +305,53 @@ export const Media: CollectionConfig = {
}
```
+3. Dynamic thumbnails via hooks
+
+```ts
+import type { CollectionConfig, AfterChangeHook, AfterReadHook } from 'payload'
+
+// Example helper that builds a CDN URL (your logic here)
+const buildThumbURL = ({ filename }: { filename?: string }) =>
+ filename ? `https://cdn.example.com/thumbs/${filename}.jpg` : undefined
+
+const setThumbURL: AfterChangeHook = async ({ doc, operation }) => {
+ // compute a thumbnail URL (first frame, resized, etc.)
+ const thumbnailURL = buildThumbURL({ filename: doc?.filename })
+ // persist to the same doc so the Admin can reuse it
+ return { ...doc, thumbnailURL }
+}
+
+const exposeThumbURL: AfterReadHook = async ({ doc }) => {
+ // ensure the field is always present on reads
+ return {
+ ...doc,
+ thumbnailURL:
+ doc.thumbnailURL ?? buildThumbURL({ filename: doc?.filename }),
+ }
+}
+
+export const Media: CollectionConfig = {
+ slug: 'media',
+ upload: true,
+ admin: {
+ // Use the field value for the Admin thumbnail
+ adminThumbnail: ({ doc }) => doc?.thumbnailURL,
+ },
+ hooks: {
+ afterChange: [setThumbURL],
+ afterRead: [exposeThumbURL],
+ },
+ fields: [
+ // store the dynamic URL (hidden from editors if you like)
+ {
+ name: 'thumbnailURL',
+ type: 'text',
+ admin: { hidden: true },
+ },
+ ],
+}
+```
+
## Restricted File Types
Possibly problematic file types are automatically restricted from being uploaded to your application.
diff --git a/templates/website/src/components/Media/ImageMedia/index.tsx b/templates/website/src/components/Media/ImageMedia/index.tsx
index 8a1dc106471..e4e7b78a4c2 100644
--- a/templates/website/src/components/Media/ImageMedia/index.tsx
+++ b/templates/website/src/components/Media/ImageMedia/index.tsx
@@ -17,6 +17,27 @@ const { breakpoints } = cssVariables
const placeholderBlur =
'data:image/png;base64,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'
+/**
+ * ImageMedia
+ *
+ * This component intentionally passes a **relative** `src` (e.g. `/media/...`),
+ * so Next.js uses its **built-in image optimization** — no custom `loader` needed.
+ *
+ * If your storage/plugin returns **absolute** URLs (e.g. `https://cdn.example.com/...`),
+ * choose ONE of the following:
+ * A) Allow the remote host in next.config.js:
+ * images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }
+ * B) Provide a per-instance **custom loader** for CDN transforms:
+ * const imageLoader: ImageLoader = ({ src, width, quality }) =>
+ * `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`
+ *
+ * C) Skip optimization for that image:
+ *
+ *
+ * TL;DR: Template defaults = relative src → no loader prop required.
+ * Only add `loader` if you’re deliberately using remote/CDN URLs or custom transforms.
+ */
+
export const ImageMedia: React.FC = (props) => {
const {
alt: altFromProps,