- TypeScript 99.9%
| src | ||
| .gitignore | ||
| justfile | ||
| package.json | ||
| plan.md | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
@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 |
enabled |
boolean |
true |
Set false to disable without removing |
replaceExistingFields |
boolean |
true |
Plugin-wide default for replacing a pre-existing top-level alt/keywords field instead of erroring on a duplicate (top-level only) |
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 |
false |
AI-powered filename suggestion banner |
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 (optional)
When enableFilenameRename: true, 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. The rename is applied to the form's filename field and saved
when the user clicks Payload's Save button.
For renaming already-saved files atomically (disk + database), a lower-level
createRenameEndpoint is available:
import { createRenameEndpoint } from '@bl0cka.de/payload-alt-text-plugin/endpoints/renameMediaFile'
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'],
}
},
}