npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@focus-reactive/payload-plugin-translator

v0.7.2

Published

Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.

Readme

@focus-reactive/payload-plugin-translator

npm version License: MIT

Translate localized content in Payload CMS 3 with any provider — a whole document, a whole collection, or a single field — straight from the admin UI.

About

Payload localizes your content, but it doesn't translate it — you still copy text between locales by hand. This plugin closes that gap: it walks every localized field (including deeply nested groups, arrays, blocks, tabs, and Lexical rich text), sends the text to a translation provider, and writes the result back to the target locale.

It works at three levels — translate the document you're editing, bulk-translate a collection from its list view, or translate a single field in place. Providers are pluggable (OpenAI is built in), and translation runs through a configurable runner (async Payload Jobs by default, or synchronously).

Features

  • Deep translation — every localized leaf field at any nesting level (groups, arrays, blocks, tabs).
  • Rich text — full Lexical translation, preserving formatting and structure.
  • Three surfaces — a per-document popup, a bulk-collection dashboard, and a per-field control, toggled via levels.
  • Async or sync — queue-based background jobs (Payload Jobs) by default, or run inline.
  • Pluggable providers — OpenAI built in, or implement your own.
  • Strategies — overwrite everything or skip locales that already have content.
  • Field control — add a per-field Translate button, or exclude a field from translation.

Requirements

| Peer dependency | Version | | ------------------------------ | -------------- | | payload | ^3.76.0 | | @payloadcms/ui | ^3.76.0 | | @payloadcms/richtext-lexical | ^3.76.0 | | react | ^18 or ^19 |

Your Payload config must have localization enabled.

Installation

npm install @focus-reactive/payload-plugin-translator
# pnpm add @focus-reactive/payload-plugin-translator
# bun add  @focus-reactive/payload-plugin-translator
# yarn add @focus-reactive/payload-plugin-translator

Quick Start

import { buildConfig } from "payload";
import { translatorPlugin, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";
import { Posts } from "./collections/Posts";
import { Pages } from "./collections/Pages";

export default buildConfig({
  collections: [Posts, Pages],
  localization: {
    locales: ["en", "de", "fr"],
    defaultLocale: "en",
  },
  plugins: [
    translatorPlugin({
      collections: [Posts, Pages], // the same config objects you pass to buildConfig
      translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
      runner: createPayloadJobsRunner(),
    }),
  ],
});

Open a localized document in the admin — a Translate control appears, and the collection list view gains a bulk dashboard.

Translation surfaces (levels)

Since v0.5.0.

levels controls which translation surfaces the plugin exposes. Each entry is a factory you import and list:

| Level | Surface | Runs | | ------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------- | | documentLevel() | A Translate popup on the document edit view (one document). | via runner | | collectionLevel() | A bulk dashboard on the collection list view (many at once). | via runner | | fieldLevel() | A per-field Translate control + a synchronous POST {basePath}/field endpoint (one field). Since v0.6.0. | synchronous, no runner |

Omit levels for the default [documentLevel(), collectionLevel()] — adopting the option is non-breaking.

import { translatorPlugin, collectionLevel, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";

translatorPlugin({
  collections: [Posts],
  translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  runner: createPayloadJobsRunner(),
  levels: [collectionLevel()], // bulk dashboard only — no per-document popup
});

The document and collection levels show a real-time progress indicator while jobs run.

Field-level translation

Since v0.6.0.

fieldLevel() adds a per-field Translate control. Two steps:

  1. Add fieldLevel() to levels (registers the endpoint).
  2. Wrap the fields that should get a control with withFieldTranslation(field).
import { translatorPlugin, documentLevel, fieldLevel, withFieldTranslation, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator";

// In a collection:
const Posts = {
  slug: "posts",
  fields: [withFieldTranslation({ name: "title", type: "text", localized: true })],
};

// In the plugin:
translatorPlugin({
  collections: [Posts],
  translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  runner: createPayloadJobsRunner(),
  levels: [documentLevel(), fieldLevel()],
});

The control is an icon button (just above the input) that opens a compact popup with the translation direction: a source-locale Select, an arrow, then the current locale (the fixed target) — en → fr. You pick the source; the target is always the locale you're editing. The server reads the source locale's saved value and translates it into the current locale, so the control needs a saved document (it's hidden while creating one). The result is written straight to form state — no save, no queue — and an Undo restores the previous value.

Allowed on text, textarea, and richText fields (a compile error on other types — pass { exclude: true } for those). For richText the Lexical editor re-mounts with the translated content. Fields inside blocks are supported: the server reads the source document's blockType to resolve the right block schema.

Localized blocks/array containers. Per-field translation works when the container is not localized — the structure is then shared across locales and only the leaf values differ, so wrap the leaves, not the container. If a blocks/array field is itself localized, each locale has an independent structure (different order/content), so a field inside it can't be matched to the source locale by position — the control no-ops with a notice to translate the whole document instead (whole-document translation handles this by matching elements by id).

This direction is intentionally the reverse of the document/collection level (which translates from the current locale to chosen targets): the field control pulls content into the locale you're standing in.

Configuration

translatorPlugin(config)

| Property | Type | Required | Default | Description | | --------------------- | --------------------- | -------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | collections | CollectionConfig[] | Yes | — | Collection configs to enable translation for. Must be the same objects passed to buildConfig, not slugs. | | translationProvider | TranslationProvider | Yes | — | Provider instance (e.g. createOpenAIProvider(...)). | | runner | TaskRunnerProvider | Yes | — | Runner for background processing (e.g. createPayloadJobsRunner()). | | access | AccessGuard | No | undefined | Access guard ({ check }) for the translation endpoints; omit to leave them open. | | basePath | string | No | '/translate' | Base path for the plugin's API endpoints. | | levels | TranslationLevel[] | No | [documentLevel(), collectionLevel()] | Which surfaces to enable — see Translation surfaces. | | provenance | boolean \| { slug?: string } | No | false (disabled) | Opt in to recording a provenance record per translation. Since v0.7.0. See Provenance below. | | lifecycle | { onQueued?, onCompleted?, onFailed? } | No | undefined | Server-side callbacks fired around each task. Since v0.7.0. See Lifecycle callbacks. |

translatorPlugin({
  collections: [Posts, Pages],
  translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  runner: createPayloadJobsRunner(),
  access: { check: ({ req }) => req.user?.role === "admin" },
});

Provenance (opt-in)

Since v0.7.0.

Set provenance: true (or {}) to record, after each successful translation, a durable per-locale provenance entry — what source state a translation was derived from. Use { slug } to customise the sidecar collection's slug (default 'translator-provenance'), e.g. to resolve a name collision with one of your own collections. Omit (or set false) to leave everything as-is: no collection, no migration, no behavior change.

Enabling it adds a plugin-managed, hidden sidecar collection to your config. On a SQL database (Postgres/SQLite) this requires a migration — run payload migrate:create then payload migrate (or let dev push apply it in development). MongoDB infers the collection with no migration step.

When a translated document is deleted, its provenance rows are cleaned up automatically (across all locales). The cleanup is best-effort — a failure is logged and never blocks the delete. The exported TranslationProvenanceRecord type describes a stored row if you query the sidecar collection directly.

translatorPlugin({
  collections: [Posts, Pages],
  translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  runner: createPayloadJobsRunner(),
  provenance: true, // or { slug: "my-provenance" }
});

Lifecycle callbacks

Since v0.7.0.

Optional server-side hooks fired around each translation task — for logging, notifications, cache invalidation, or feeding a dashboard. They need no schema or migration and are independent of the provenance opt-in. Each receives a TranslationTask descriptor ({ collection, id, sourceLng, targetLng, strategy }); onFailed also receives the error.

A callback that throws is caught and logged — it never fails the translation. onCompleted / onFailed fire per execution attempt (the Payload Jobs runner may retry a failed task); onQueued fires once at enqueue.

translatorPlugin({
  collections: [Posts, Pages],
  translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
  runner: createPayloadJobsRunner(),
  lifecycle: {
    onQueued: (task) => console.log("queued", task),
    onCompleted: (task) => console.log("done", task),
    onFailed: (task, error) => console.error("failed", task, error),
  },
});

Providers

OpenAI (built in) — createOpenAIProvider(config)

| Property | Type | Required | Default | Description | | -------------- | ------------------------- | -------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | | apiKey | string | Yes | — | OpenAI API key. | | model | string \| ChatModel | No | 'gpt-4o' | Model used for translation. | | systemPrompt | SystemPromptBuilder | No | Built-in prompt | Custom system-prompt builder. | | dryRun | boolean \| DryRunConfig | No | false | Simulate translations without API calls. | | timeout | number | No | SDK default (10 min) | Per-request timeout (ms). A job blocks on this call, so the 10-min default is usually too long. Since v0.6.0. | | maxRetries | number | No | SDK default (2) | Max automatic retries on transient errors (429/5xx/network). 0 disables. Since v0.6.0. |

createOpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,
  model: "gpt-4o-mini",
  systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) => `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
});

systemPrompt receives { sourceLang, targetLang, defaultPrompt } and returns the prompt string. When dryRun is an object it can transform text locally with an optional delay:

type DryRunConfig = {
  transform: (text: string) => string | Promise<string>;
  timeout?: number; // ms, simulates API latency
};

Custom provider

Implement the TranslationProvider interface — a single translate method:

import type { TranslationProvider, TranslationInput, TranslationOutput } from "@focus-reactive/payload-plugin-translator";

// TranslationInput / TranslationOutput are Record<number, string> — a map of
// numeric indices to text. The indices map to positions in the document; the
// provider MUST return the same keys with translated values.
class DeepLProvider implements TranslationProvider {
  constructor(private apiKey: string) {}

  async translate(content: TranslationInput, sourceLng: string, targetLng: string): Promise<TranslationOutput | null> {
    try {
      const response = await fetch("https://api.deepl.com/v2/translate", {
        method: "POST",
        headers: { Authorization: `DeepL-Auth-Key ${this.apiKey}`, "Content-Type": "application/json" },
        body: JSON.stringify({ text: Object.values(content), source_lang: sourceLng.toUpperCase(), target_lang: targetLng.toUpperCase() }),
      });
      const data = await response.json();

      const result: TranslationOutput = {};
      Object.keys(content).forEach((key, i) => {
        result[key] = data.translations[i].text;
      });
      return result;
    } catch {
      return null; // null aborts the translation for this chunk
    }
  }
}

translatorPlugin({
  collections: [Posts],
  translationProvider: new DeepLProvider(process.env.DEEPL_API_KEY),
  runner: createPayloadJobsRunner(),
});

Runners

Document- and collection-level translation run through a runner.

createPayloadJobsRunner(options) (recommended)

Background processing via Payload's job queue.

| Property | Type | Required | Default | Description | | ----------- | -------------------------- | -------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------- | | taskName | string | No | 'translate_document' | Task name in the Payload jobs collection. | | queueName | string | No | 'translations' | Queue name for grouping jobs. | | autoRun | false \| { cron, limit } | No | { cron: '* * * * *', limit: 50 } | Auto-run schedule, or false to disable (e.g. for serverless, where you trigger the queue yourself). |

createPayloadJobsRunner({ taskName: "translate_document", queueName: "translations", autoRun: { cron: "* * * * *", limit: 50 } });

By default Payload deletes a job as soon as it completes, so the "Completed" status never shows in the UI. Set jobs: { deleteJobOnComplete: false } in your Payload config to keep it.

createSyncRunner()

Runs translations inline (no queue) — handy for development or small datasets.

import { createSyncRunner } from "@focus-reactive/payload-plugin-translator";

translatorPlugin({ collections: [Posts], translationProvider, runner: createSyncRunner() });

Field config — withFieldTranslation(field, config?)

A plain wrap on a text / textarea / richText field adds the per-field Translate control (requires fieldLevel()); { exclude: true } opts a field out of translation entirely.

| Property | Type | Required | Default | Description | | --------- | --------- | -------- | ------- | ------------------------------------ | | exclude | boolean | No | false | Exclude this field from translation. |

import { withFieldTranslation } from "@focus-reactive/payload-plugin-translator";

withFieldTranslation({ name: "title", type: "text", localized: true }); // adds the control
withFieldTranslation({ name: "sku", type: "text", localized: true }, { exclude: true }); // never translated

Strategies

How existing target-locale content is treated when translating:

| Strategy | Behavior | | ----------------- | ---------------------------------------------------------- | | 'overwrite' | (Default) Replace all existing translated content. | | 'skip_existing' | Only translate fields that are empty in the target locale. |

Notes & gotchas

Mark nested fields localized: true explicitly

Payload lets a wrapper field (group, array, blocks, tabs) be localized, which makes nested fields inherit localization. The plugin, however, only translates leaf fields (text, textarea, richText), so each one you want translated must carry localized: true itself:

// ❌ nested title is not explicitly localized — skipped
{ name: "meta", type: "group", localized: true, fields: [{ name: "title", type: "text" }] }

// ✅ title is explicitly localized — translated
{ name: "meta", type: "group", localized: true, fields: [{ name: "title", type: "text", localized: true }] }

Excluded fields are still backfilled

{ exclude: true } means "never send this field to the provider" — not "leave it untouched." If an excluded field is empty in the target locale, it's filled from the source locale (so required fields don't fail validation on save). Exclusion takes priority over the overwrite strategy: an excluded field keeps its target value if present, copies the source value only when target is empty, and is never sent to the provider.

Keeping completed-job status

See the deleteJobOnComplete: false note under Runners.

TypeScript

The package ships its types. Besides the factories, the following are exported for typing your own code:

import type {
  TranslatorPluginConfig,
  TranslationProvider,
  TranslationInput,
  TranslationOutput,
  OpenAIProviderConfig,
  DryRunConfig,
  TaskRunnerProvider,
  PayloadJobsRunnerOptions,
  TranslationLevel,
  FieldTranslationConfig,
  AccessGuard,
  AccessGuardRequest,
  TranslationTask, // descriptor passed to the lifecycle callbacks — Since v0.7.0
  TranslationLifecycleCallbacks, // shape of the `lifecycle` config — Since v0.7.0
  TranslationProvenanceRecord, // a stored provenance row — Since v0.7.0
} from "@focus-reactive/payload-plugin-translator";

Versioning

Every public API is annotated with @since x.y.z in its JSDoc, and features carry a Since vX.Y.Z note here — so you can tell at a glance whether your installed version has a given capability without cross-referencing the changelog. Releases follow semver.

Roadmap

Planned features building on the v0.7.0 provenance foundation:

  • Stale-translation detection — surface which target locales are out of sync with their source, using the recorded provenance fingerprint.
  • Global translation dashboard — translate across all collections from one place, with project-wide progress.
  • Auto-translate on source change — retranslate automatically when default-locale content changes.

License

MIT © Focus Reactive.