@bl0cka.de/payload-alt-text-plugin (0.2.0)
Installation
@bl0cka.de:registry=npm install @bl0cka.de/payload-alt-text-plugin@0.2.0"@bl0cka.de/payload-alt-text-plugin": "0.2.0"About this package
@bl0cka.de/payload-alt-text-plugin
A Payload CMS v3 plugin that adds AI-powered alt text (and keyword) generation to any upload-enabled collection or global — with a swappable AI resolver.
How it works
- User selects an image in the Payload admin
- The image is resized on the client (OffscreenCanvas, no extra server load)
- The small thumbnail is POSTed to a custom Payload endpoint
- The endpoint delegates to your chosen resolver (Claude, OpenAI, or custom)
- The
altandaltKeywordsfields are filled in automatically - If the user has already started typing when the generation finishes, a conflict banner appears — offering to Overwrite, Append, Copy, or Keep
The user can always edit the generated text and click Regenerate at any time. Nothing is saved until the user clicks Payload's normal Save button.
Installation
pnpm add @bl0cka.de/payload-alt-text-plugin
pnpm add @anthropic-ai/sdk # if using claudeResolver
pnpm add openai # if using openAIResolver
This package ships pre-compiled ESM with bundled type declarations, so it works
out of the box in any Next.js or Node.js setup — no transpilePackages entry is
required.
File structure
src/
├── index.ts # Plugin entry point + public exports
├── types.ts # All shared TypeScript types
├── components/
│ └── AltTextGeneratorField.tsx # 'use client' field component
├── endpoints/
│ ├── generateAltText.ts # Payload custom endpoint (server only)
│ └── renameMediaFile.ts # Optional: atomic file rename endpoint
├── lib/
│ └── resizeImage.ts # Client-side canvas resize utility
└── resolvers/
├── index.ts # Re-exports all resolvers
├── claudeResolver.ts # Anthropic Claude vision resolver
└── openAIResolver.ts # OpenAI GPT-4o resolver
Minimal usage
// payload.config.ts
import { buildConfig } from 'payload'
import { altTextPlugin } from '@bl0cka.de/payload-alt-text-plugin'
import { claudeResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'
export default buildConfig({
plugins: [
altTextPlugin({
resolver: claudeResolver({ apiKey: process.env.ANTHROPIC_API_KEY! }),
collections: ['media'],
}),
],
})
All options
Top-level AltTextPluginOptions
| Option | Type | Default | Description |
|---|---|---|---|
resolver |
AltTextResolver |
required | AI resolver to use |
collections |
Array<string | AltTextCollectionConfig> |
[] |
Upload collections to target |
globals |
Array<string | AltTextGlobalConfig> |
[] |
Globals to target |
apiEndpoint |
string |
'/api/generate-alt-text' |
URL the client POSTs to |
renameApiEndpoint |
string |
'/api/rename-media-file' |
URL used to rename a saved upload's file |
rateLimit |
{ max, windowMs } | false |
{ max: 30, windowMs: 60000 } |
Per-user limit on the generation endpoint (in-memory, per-instance). false to disable |
enabled |
boolean |
true |
Set false to disable without removing |
Additionally, every per-entity option below can also be set at the top level
as a plugin-wide default for all targeted collections and globals. The
per-entity value always wins (entity ?? pluginWide ?? built-in default):
altTextPlugin({
resolver,
enableKeywords: false, // plugin-wide default
thumbnailMaxWidth: 768, // plugin-wide default
collections: [
'media',
{ slug: 'product-images', enableKeywords: true }, // entity override wins
],
})
Per-entity options (AltTextCollectionConfig / AltTextGlobalConfig)
| Option | Type | Default | Description |
|---|---|---|---|
slug |
string |
required | Collection/global slug |
altFieldName |
string |
'alt' |
Name of the alt text field |
keywordsFieldName |
string |
'altKeywords' |
Name of the keywords field |
enableKeywords |
boolean |
true |
Include the keywords hasMany field |
required |
boolean |
true |
Make alt text required on save |
altFieldOverrides |
Partial<TextField> |
{} |
Merge extra config onto the alt field |
keywordsFieldOverrides |
Partial<TextField> |
{} |
Merge extra config onto the keywords field |
defaultLocale |
string |
'en' |
Fallback locale for generation |
thumbnailMaxWidth |
number |
512 |
Client-side resize max width (px) |
thumbnailQuality |
number |
0.8 |
Client-side JPEG quality (0–1) |
autoGenerate |
boolean |
true |
Auto-generate on file select |
autoGenerateOnOpen |
boolean |
true |
Auto-generate when opening a saved doc with empty alt |
overwriteOnAutoGenerate |
boolean |
false |
Silently overwrite on auto-generate |
enableFilenameRename |
boolean |
true |
AI-powered filename suggestion banner |
generateOnUpload |
boolean |
false |
Generate alt text server-side on upload (covers native bulk upload; see below) |
replaceExistingFields |
boolean |
true |
Replace a pre-existing top-level alt/keywords field instead of erroring on a duplicate (top-level only; overrides the plugin-wide value) |
uploadFieldName (globals only) |
string |
'image' |
Form field whose File to use |
Existing alt fields: Upload collections often already declare an
altfield. By default the plugin replaces any colliding top-level field with its own (which wires up the generator UI), so you don't hit Payload's duplicate field-name error. SetreplaceExistingFields: falseto keep your own field instead (then use a distinctaltFieldNameto avoid the collision). Only top-level fields are scanned — not fields nested in tabs/rows/groups.
Payload plugin API: On Payload ≥ 3.83 the plugin registers via the experimental
definePluginAPI, exposing aslug(payload-alt-text-plugin) for cross-plugin discovery. On older versions it falls back to a plain plugin factory automatically — no minimum-version requirement.
Swapping the AI provider
// Use Claude (fast and cost-effective)
import { claudeResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'
resolver: claudeResolver({
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-sonnet-4-5', // optional, default: 'claude-haiku-4-5'
})
// Use OpenAI
import { openAIResolver } from '@bl0cka.de/payload-alt-text-plugin/resolvers'
resolver: openAIResolver({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o', // optional, default: 'gpt-4o-mini'
})
// Bring your own
const myResolver: AltTextResolver = {
key: 'my-provider',
resolve: async ({ fileBuffer, filename, locale }) => {
// ... call your API
return { altText: '...', keywords: ['...'] }
},
}
Advanced example
altTextPlugin({
resolver: claudeResolver({ apiKey: process.env.ANTHROPIC_API_KEY! }),
enabled: process.env.NODE_ENV !== 'test',
collections: [
// Shorthand — all defaults
'media',
// Full config
{
slug: 'product-images',
altFieldName: 'imageAlt',
keywordsFieldName: 'imageKeywords',
enableKeywords: true,
required: true,
defaultLocale: 'de',
thumbnailMaxWidth: 768,
thumbnailQuality: 0.85,
autoGenerate: true,
overwriteOnAutoGenerate: false,
enableFilenameRename: true,
altFieldOverrides: {
label: 'Image description',
localized: true,
admin: {
description: 'Required for accessibility. Auto-generated from the image.',
},
},
},
// No keywords, manual generation only
{
slug: 'avatars',
enableKeywords: false,
autoGenerate: false,
required: false,
},
],
globals: [
{
slug: 'seo',
uploadFieldName: 'openGraphImage',
altFieldName: 'ogImageAlt',
enableKeywords: false,
},
],
})
Conflict resolution banner
When autoGenerate: true and the user starts typing in the alt text field before
generation finishes, the component shows a banner with four choices:
- Overwrite — replace typed text with the generated text
- Append — join the typed text and generated text with a sentence separator
- Copy to clipboard — copy generated text to clipboard, keep typed text as-is
- Keep mine — dismiss the banner, discard the generated text
Set overwriteOnAutoGenerate: true to skip the banner and always overwrite silently.
Filename rename
When enableFilenameRename is active (the default), the AI also evaluates whether
the current filename is descriptive and suggests a better one if not. A banner
appears offering to rename or keep the original. Set enableFilenameRename: false
to turn the suggestion off.
For already-saved documents a dedicated Suggest filename button appears next to Generate: it asks the AI to evaluate the current filename only, without touching the alt text or keywords fields. If the name is judged descriptive, a confirmation note is shown instead of a banner.
How the rename is applied depends on whether the document has been saved yet:
- New (unsaved) upload — the suggestion is written to the form's
filenamefield and stored under that name when you click Payload's Save button. - Already-saved upload — clicking Rename calls the plugin's
/api/rename-media-fileendpoint, which re-stores the file under the new name through Payload's own upload pipeline. This regenerates the image size variants, rewritesfilename/url/sizes.*in the database, and removes the old files. Because it goes throughpayload.update, it works on all database adapters (MongoDB, Postgres, SQLite) and any storage adapter — no manual wiring required.
The rename reads the original file from disk (local storage). The size variants are regenerated (re-encoded) on rename; the original is preserved byte-for-byte.
The endpoint is registered automatically. Its path can be customised with the
renameApiEndpoint plugin option (default /api/rename-media-file). The lower-level
factory is also exported if you need to mount it yourself:
import { createRenameEndpoint } from '@bl0cka.de/payload-alt-text-plugin/endpoints/renameMediaFile'
Bulk upload / server-side generation (optional)
The generator UI is client-driven, so it doesn't run during Payload's native
bulk upload (many docs created at once, no field UI in the loop) — those images
would land with an empty alt, and a required: true alt would even reject the
bulk create.
Set generateOnUpload: true (per-entity or plugin-wide) to also generate alt
text server-side in a beforeChange hook, reading the uploaded file directly.
Because it runs before validation and only fills alt when empty, it covers bulk
upload and satisfies a required alt field. Failures are logged and never block the
upload. Note: it adds one AI call per qualifying upload, so it's opt-in.
altTextPlugin({
resolver: claudeResolver({ apiKey: process.env.ANTHROPIC_API_KEY! }),
collections: [{ slug: 'media', generateOnUpload: true }],
})
Writing a custom resolver
import type { AltTextResolver } from '@bl0cka.de/payload-alt-text-plugin'
export const myResolver: AltTextResolver = {
key: 'my-backend',
resolve: async ({ fileBuffer, filename, locale }) => {
const base64 = fileBuffer.toString('base64')
// ... call your API with base64, filename, locale
return {
altText: 'A descriptive alt text string',
keywords: ['keyword1', 'keyword2'],
}
},
}
Dependencies
Dependencies
| ID | Version |
|---|---|
| ts-essentials | ^10.0.0 |
Development dependencies
| ID | Version |
|---|---|
| release-it | ^20.2.0 |
| tsdown | ^0.15.12 |
| typescript | ^5.9.3 |
| vitest | ^4.1.8 |
Peer dependencies
| ID | Version |
|---|---|
| @payloadcms/ui | >=3 |
| payload | >=3 |
| react | >=18 |
Optional dependencies
| ID | Version |
|---|---|
| @anthropic-ai/sdk | >=0.20 |
| openai | >=4 |