From 9386e673c9bb0c79f462e46c8aee160841646ea4 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Fri, 5 Sep 2025 14:05:44 -0400 Subject: [PATCH 1/7] updates dockerfile example in production docs --- docs/production/deployment.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index 1865133eced..6adcd82869a 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.17.0-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 From 8cc630f5b2ff2b70d099f26311d8a26dfd87de86 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Mon, 15 Sep 2025 13:15:32 -0400 Subject: [PATCH 2/7] updates documentation to include how to send email with attachments for nodemailer and resend --- docs/email/overview.mdx | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/email/overview.mdx b/docs/email/overview.mdx index 5dff8bcf458..588a62987c9 100644 --- a/docs/email/overview.mdx +++ b/docs/email/overview.mdx @@ -158,6 +158,69 @@ const email = await payload.sendEmail({ }) ``` +## Sending email with attachments + +**Nodemailer adapter (SMTP/SendGrid/etc.)** + +Works with `@payloadcms/email-nodemailer` and any Nodemailer transport. + +``` +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 + +``` +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 + +``` +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). From 0d90edc7343beda54fc08e684db16d242a24e9e0 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Mon, 15 Sep 2025 16:12:55 -0400 Subject: [PATCH 3/7] updates docs for unique option on select field --- docs/fields/select.mdx | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index b2fbfca3e7e..e8adadbd1a7 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: + +``` +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. From 5ace5e809fde8a3a8d2a2c9393688614c054ae45 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Tue, 16 Sep 2025 11:43:01 -0400 Subject: [PATCH 4/7] - Updates node version in Dockerfile example in production - Adds comment to ImageMedia component in website template for loader - adds example for dynamic admin thumbnail --- docs/production/deployment.mdx | 2 +- docs/upload/overview.mdx | 39 +++++++++++++++++++ .../src/components/Media/ImageMedia/index.tsx | 21 ++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index 6adcd82869a..5fbe2ebdf2a 100644 --- a/docs/production/deployment.mdx +++ b/docs/production/deployment.mdx @@ -236,7 +236,7 @@ version: '3' services: payload: - image: node:18-alpine + image: node:20-alpine ports: - '3000:3000' volumes: diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 1167c957bf2..8a892b9d7ef 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -305,6 +305,45 @@ export const Media: CollectionConfig = { } ``` +3. Dynamic thumbnails via hooks + +``` +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: { readOnly: true, condition: () => false } }, + ], +} +``` + ## 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, From 6e7f509874391be81895b139ac27bdf6dce4e443 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Thu, 18 Sep 2025 11:51:32 -0400 Subject: [PATCH 5/7] adds ts to code snippets for correct highlighting --- docs/email/overview.mdx | 28 ++++++++++++++++++---------- docs/fields/select.mdx | 16 ++++++++-------- docs/upload/overview.mdx | 14 +++++++++++--- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/email/overview.mdx b/docs/email/overview.mdx index 588a62987c9..cde743128cd 100644 --- a/docs/email/overview.mdx +++ b/docs/email/overview.mdx @@ -164,18 +164,26 @@ const email = await payload.sendEmail({ 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' }, + { + 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' }, + { + 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. @@ -186,7 +194,7 @@ Works with @payloadcms/email-resend. For attachments from remote URLs -``` +```ts await payload.sendEmail({ to: 'user@example.com', subject: 'Your invoice', @@ -198,15 +206,15 @@ await payload.sendEmail({ filename: 'invoice-1234.pdf', }, ], -}); +}) ``` For a local file -``` -import { readFile } from 'node:fs/promises'; +```ts +import { readFile } from 'node:fs/promises' -const pdf = await readFile('/var/data/invoice.pdf'); +const pdf = await readFile('/var/data/invoice.pdf') await payload.sendEmail({ to: 'user@example.com', subject: 'Your invoice', @@ -218,7 +226,7 @@ await payload.sendEmail({ content: pdf.toString('base64'), }, ], -}); +}) ``` ## Using multiple mail providers diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index e8adadbd1a7..02be919e134 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -84,8 +84,8 @@ standard unique index makes sense. Example: -``` -import type { Field } from 'payload'; +```ts +import type { Field } from 'payload' export const ItemsArray: Field = { name: 'items', @@ -97,16 +97,16 @@ export const ItemsArray: Field = { // 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); + 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.'; + codes.filter((c, i) => codes.indexOf(c) !== i), + ) + return duplicates.size === 0 || 'Codes in this array must be unique.' }, }, ], -}; +} ``` ### filterOptions diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 8a892b9d7ef..02d41b7b59c 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -307,7 +307,7 @@ 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) @@ -323,7 +323,11 @@ const setThumbURL: AfterChangeHook = async ({ doc, operation }) => { const exposeThumbURL: AfterReadHook = async ({ doc }) => { // ensure the field is always present on reads - return { ...doc, thumbnailURL: doc.thumbnailURL ?? buildThumbURL({ filename: doc?.filename }) } + return { + ...doc, + thumbnailURL: + doc.thumbnailURL ?? buildThumbURL({ filename: doc?.filename }), + } } export const Media: CollectionConfig = { @@ -339,7 +343,11 @@ export const Media: CollectionConfig = { }, fields: [ // store the dynamic URL (hidden from editors if you like) - { name: 'thumbnailURL', type: 'text', admin: { readOnly: true, condition: () => false } }, + { + name: 'thumbnailURL', + type: 'text', + admin: { readOnly: true, condition: () => false }, + }, ], } ``` From 8f06d2ececc1a28228e9f3f4e573978d0ee8d6b9 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Wed, 1 Oct 2025 13:08:53 -0400 Subject: [PATCH 6/7] updates both references to node version to 22-alpine --- docs/production/deployment.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index 5fbe2ebdf2a..ac2f06e4dd4 100644 --- a/docs/production/deployment.mdx +++ b/docs/production/deployment.mdx @@ -157,7 +157,7 @@ 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:22.17.0-alpine AS base +FROM node:22-alpine AS base # Install dependencies only when needed FROM base AS deps @@ -236,7 +236,7 @@ version: '3' services: payload: - image: node:20-alpine + image: node:22-alpine ports: - '3000:3000' volumes: From 38d3efbf6b3eec754936c39adc13f96cd0646ed0 Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Wed, 1 Oct 2025 13:20:36 -0400 Subject: [PATCH 7/7] updates code snippet in uploads --- docs/upload/overview.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 02d41b7b59c..bb954ae57e4 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -346,7 +346,7 @@ export const Media: CollectionConfig = { { name: 'thumbnailURL', type: 'text', - admin: { readOnly: true, condition: () => false }, + admin: { hidden: true }, }, ], }