• Home
BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Payload
  4. Automate Payload CMS Translations with OpenAI - 9x Faster

Automate Payload CMS Translations with OpenAI - 9x Faster

Use Payload job queues, OpenAI structured output, and Zod to auto-translate nested localized fields (SL → EN/RU)…

1st January 2026·Updated on:18th January 2026·MŽMatija Žiberna·
Payload
Automate Payload CMS Translations with OpenAI - 9x Faster

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

I was building a multilingual e-commerce site with Payload CMS when I realized I had a problem. The client needed their entire menu system—complete with nested dropdowns and descriptions—translated from Slovenian into English and Russian. We're talking about dozens of menu items, each with multiple localized fields. Doing this manually would take hours, and worse, it would need to happen every time content changed.

After exploring different approaches, I discovered that Payload's built-in job queue system combined with OpenAI's structured output API could solve this elegantly. The solution I built not only handled the initial translation but also intelligently skipped already-translated content and worked around Payload's validation constraints when updating nested localized fields.

This guide walks you through building an automated translation system for Payload CMS. While I'm using a menu collection as the example, the pattern works for any collection with localized fields—pages, products, posts, you name it.

Understanding the Challenge

Payload CMS handles localization beautifully. When you mark a field as localized: true, Payload stores each language variant in a structured format. For a text field like title, instead of storing a simple string, Payload stores an object:

{
  title: {
    sl: "Ponudba",
    en: "Offer",
    ru: "Предложение"
  }
}

This works great for reading content, but updating these fields programmatically—especially in nested array structures—presents several challenges. First, when you fetch a document with locale: 'all', Payload only returns the locale keys that have been saved. If English and Russian translations don't exist yet, you get {sl: "Ponudba"} instead of {sl: "Ponudba", en: null, ru: null}. Second, Payload's validation runs on the entire document when you update it, which means conditional required fields (like our menu's customPath field) can block your translation updates. Third, navigating nested array paths like menuItems[0].children[2].description and updating just the English value requires careful handling.

The solution needs to detect which fields need translation, call OpenAI for each one, and update the database without triggering validation errors or corrupting the document structure.

Architecture Overview

Before diving into code, let's look at how the pieces fit together. We'll use Payload's job queue system to handle the translation process asynchronously. When triggered via an API endpoint, a job gets queued with the task translateMenus. The job handler fetches all menu documents, identifies fields that need translation, calls OpenAI's API for each field individually, and updates the document.

We're using OpenAI's structured output feature with Zod schemas to ensure we get back properly formatted translations. The field detection logic checks both whether a locale key exists and whether it's empty, handling Payload's sparse locale object structure. For updates, we fetch the document in the target locale, fill in any validation-required placeholders, update the specific field, and save it back.

This approach keeps translation atomic per field, allows for detailed error tracking, and respects rate limits through configurable delays between API calls.

Setting Up Dependencies

Start by installing the required packages. We need OpenAI's official SDK for API calls and Zod for validating the structured responses:

pnpm add openai zod

The OpenAI package provides typed access to their API, including the newer structured output features. Zod gives us runtime type validation, which pairs perfectly with OpenAI's response_format parameter to guarantee we receive translations in the expected format.

Creating the Translation Schema

We'll use Zod to define exactly what shape we expect back from OpenAI. Create a schemas directory and add the translation schema:

// File: src/lib/openai/schemas/translation.ts
import { z } from "zod";

export const SingleTranslation = z.object({
  translation: z.string(),
});

export type SingleTranslationType = z.infer<typeof SingleTranslation>;

This simple schema ensures OpenAI returns an object with a translation property containing a string. When we send a request to OpenAI with this schema, the API will structure its response to match exactly. No more parsing JSON strings or handling unexpected response formats.

Building the OpenAI Client

Next, create a singleton client for OpenAI to reuse the same instance across translation calls:

// File: src/lib/openai/client.ts
import OpenAI from 'openai';

let openaiClient: OpenAI | null = null;

export function getOpenAIClient(): OpenAI {
  if (!openaiClient) {
    openaiClient = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
    });
  }
  return openaiClient;
}

The singleton pattern prevents creating multiple OpenAI instances during a job run. The client reads your API key from the environment variable OPENAI_API_KEY, which you'll need to add to your .env file.

Implementing Single Field Translation

Now we'll create the function that handles translating a single field. This is where we leverage OpenAI's structured output:

// File: src/lib/openai/translateSingle.ts
import OpenAI from 'openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import { SingleTranslation } from './schemas/translation';

const LOCALE_NAMES: Record<'en' | 'ru', string> = {
  en: 'English',
  ru: 'Russian',
};

export async function translateSingle(
  openai: OpenAI,
  text: string,
  targetLocale: 'en' | 'ru'
): Promise<string> {
  const completion = await openai.chat.completions.parse({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `You are a translator for an organic farm e-commerce website selling meat and farm products.
Translate Slovenian to ${LOCALE_NAMES[targetLocale]}.
Keep translations natural for food/agriculture context.
Return only the translation, nothing else.`,
      },
      {
        role: 'user',
        content: text,
      },
    ],
    response_format: zodResponseFormat(SingleTranslation, 'translation'),
  });

  return completion.choices[0].message.parsed?.translation ?? '';
}

The key here is response_format: zodResponseFormat(SingleTranslation, 'translation'). This tells OpenAI to structure its response according to our Zod schema. The system prompt provides context about the domain (organic farm, food products) so translations use appropriate terminology. We're using gpt-4o-mini because it's fast and cost-effective for straightforward translation tasks.

Notice we're translating one field at a time rather than batching. This gives us granular control—if one translation fails, the others still succeed. It also makes error tracking precise and allows us to add delays between calls for rate limiting.

Detecting Empty Translatable Fields

The trickiest part of this system is identifying which fields need translation. Remember, Payload only includes locale keys that have values, so we need to check both for missing keys and empty values:

// File: src/lib/translations/findEmptyFields.ts
export interface TranslatableField {
  path: string;
  sourceText: string;
  targetLocale: 'en' | 'ru';
}

type LocalizedString = string | { sl?: string; en?: string; ru?: string };

function getLocalizedValue(field: LocalizedString, locale: 'sl' | 'en' | 'ru'): string | undefined {
  if (typeof field === 'string') {
    return locale === 'sl' ? field : undefined;
  }
  if (field && typeof field === 'object') {
    return field[locale];
  }
  return undefined;
}

function isEmpty(value: string | undefined | null): boolean {
  return !value || value.trim() === '';
}

function hasLocaleKey(field: LocalizedString, locale: 'en' | 'ru'): boolean {
  if (typeof field === 'string') {
    return false;
  }
  if (field && typeof field === 'object') {
    return locale in field;
  }
  return false;
}

export function findEmptyTranslatableFields(doc: any): TranslatableField[] {
  const fields: TranslatableField[] = [];
  const targetLocales = ['en', 'ru'] as const;

  doc.menuItems?.forEach((item: any, i: number) => {
    for (const locale of targetLocales) {
      const slValue = getLocalizedValue(item.title, 'sl');
      const localeValue = getLocalizedValue(item.title, locale);

      if (!isEmpty(slValue) && (isEmpty(localeValue) || !hasLocaleKey(item.title, locale))) {
        fields.push({
          path: `menuItems.${i}.title`,
          sourceText: slValue!,
          targetLocale: locale,
        });
      }
    }

    item.children?.forEach((child: any, j: number) => {
      for (const locale of targetLocales) {
        const slTitle = getLocalizedValue(child.title, 'sl');
        const localeTitle = getLocalizedValue(child.title, locale);

        if (!isEmpty(slTitle) && (isEmpty(localeTitle) || !hasLocaleKey(child.title, locale))) {
          fields.push({
            path: `menuItems.${i}.children.${j}.title`,
            sourceText: slTitle!,
            targetLocale: locale,
          });
        }

        const slDesc = getLocalizedValue(child.description, 'sl');
        const localeDesc = getLocalizedValue(child.description, locale);

        if (!isEmpty(slDesc) && (isEmpty(localeDesc) || !hasLocaleKey(child.description, locale))) {
          fields.push({
            path: `menuItems.${i}.children.${j}.description`,
            sourceText: slDesc!,
            targetLocale: locale,
          });
        }
      }
    });
  });

  return fields;
}

This function traverses the menu structure looking for three types of fields: top-level menu item titles, child item titles, and child item descriptions. For each field, it checks if there's Slovenian content and either the target locale is missing entirely or it's empty. The hasLocaleKey helper is crucial—it detects when a locale key simply doesn't exist in the object yet.

The function returns an array of TranslatableField objects, each containing the field path (like menuItems.1.children.2.description), the source Slovenian text, and the target locale. This gives the translation handler everything it needs to process each field.

Notice we're intentionally skipping customPath fields. These are URL slugs that should be manually set per locale, not auto-translated.

Updating Fields Without Breaking Validation

Updating a single nested field in Payload while avoiding validation errors requires a specific approach. The challenge is that when we update a document, Payload validates all required fields—even ones we're not changing. In our menu, customPath is required when linkType is custom, but these paths might be null in the target locale:

// File: src/lib/translations/updateField.ts
import { Payload } from 'payload';

export async function updateSingleField(
  payload: Payload,
  docId: number | string,
  fieldPath: string,
  locale: 'en' | 'ru',
  value: string
): Promise<void> {
  const doc: any = await payload.findByID({
    collection: 'menus',
    id: docId,
    locale: locale,
    depth: 0,
  });

  if (doc.menuItems) {
    doc.menuItems.forEach((item: any) => {
      if (item.linkType === 'custom' && !item.customPath) {
        item.customPath = '/placeholder';
      }
      if (item.children) {
        item.children.forEach((child: any) => {
          if (child.linkType === 'custom' && !child.customPath) {
            child.customPath = '/placeholder';
          }
        });
      }
    });
  }

  const pathParts = fieldPath.split('.');
  let current: any = doc;

  for (let i = 0; i < pathParts.length - 1; i++) {
    const part = pathParts[i];
    const index = parseInt(part);

    if (!isNaN(index)) {
      current = current[index];
    } else {
      current = current[part];
    }

    if (!current) {
      throw new Error(`Cannot navigate path ${fieldPath} - ${part} is undefined`);
    }
  }

  const finalField = pathParts[pathParts.length - 1];
  current[finalField] = value;

  await payload.update({
    collection: 'menus',
    id: docId,
    locale: locale,
    data: doc,
  });
}

The key insight here is filling in placeholder values for any required fields that are empty before saving. We fetch the document in the target locale (not locale: 'all'), scan for any customPath fields that are null, and temporarily set them to /placeholder. This satisfies Payload's validation without corrupting the actual data structure.

Then we parse the field path string (like menuItems.1.children.2.title) and navigate through the document object, handling both array indices (numeric parts) and property names. When we reach the target field, we update its value and save the entire document back.

This approach is simpler and more reliable than trying to construct complex update objects or using raw SQL. We let Payload handle the database interaction while working around its validation constraints.

Creating the Payload Job Handler

Now we tie everything together in a Payload job handler. This is the function that gets executed when the translation job runs:

// File: src/payload/jobs/tasks/translateMenus.ts
import { getOpenAIClient } from '@/lib/openai/client';
import { translateSingle } from '@/lib/openai/translateSingle';
import { findEmptyTranslatableFields } from '@/lib/translations/findEmptyFields';
import { updateSingleField } from '@/lib/translations/updateField';

interface TranslationResult {
  success: boolean;
  totalDocuments: number;
  totalFieldsTranslated: number;
  errors: Array<{
    docId: number | string;
    field: string;
    locale: string;
    error: string;
  }>;
}

const translateMenusHandler = async ({
  req,
}: {
  input?: Record<string, never>;
  req: any;
}) => {
  req.payload.logger.info(`=== MENU TRANSLATION JOB STARTED ===`);

  const openai = getOpenAIClient();
  const result: TranslationResult = {
    success: true,
    totalDocuments: 0,
    totalFieldsTranslated: 0,
    errors: [],
  };

  try {
    const { docs } = await req.payload.find({
      collection: 'menus',
      locale: 'all',
      depth: 2,
      limit: 100,
    });

    result.totalDocuments = docs.length;
    req.payload.logger.info(`Found ${docs.length} menu documents`);

    for (const doc of docs) {
      const fieldsToTranslate = findEmptyTranslatableFields(doc);
      req.payload.logger.info(`Menu ${doc.id}: Found ${fieldsToTranslate.length} fields to translate`);

      for (const field of fieldsToTranslate) {
        try {
          req.payload.logger.info(`Translating ${field.path} to ${field.targetLocale}`);

          const translated = await translateSingle(
            openai,
            field.sourceText,
            field.targetLocale
          );

          await updateSingleField(
            req.payload,
            doc.id,
            field.path,
            field.targetLocale,
            translated
          );

          result.totalFieldsTranslated++;
          req.payload.logger.info(`Successfully translated ${field.path} to ${field.targetLocale}`);

          await new Promise((resolve) => setTimeout(resolve, 100));

        } catch (error) {
          const errorMsg = error instanceof Error ? error.message : 'Unknown error';
          req.payload.logger.error(`Failed to translate ${field.path}: ${errorMsg}`);
          result.errors.push({
            docId: doc.id,
            field: field.path,
            locale: field.targetLocale,
            error: errorMsg,
          });
        }
      }
    }

    result.success = result.errors.length === 0;

    req.payload.logger.info(`=== MENU TRANSLATION COMPLETED ===`);
    req.payload.logger.info(`Translated ${result.totalFieldsTranslated} fields across ${result.totalDocuments} documents`);

    return {
      output: result,
    };

  } catch (error) {
    const errorMsg = error instanceof Error ? error.message : 'Failed to fetch menus';
    req.payload.logger.error(`=== MENU TRANSLATION ERROR ===`);
    req.payload.logger.error(errorMsg);

    result.success = false;
    result.errors.push({
      docId: 'N/A',
      field: 'N/A',
      locale: 'N/A',
      error: errorMsg,
    });

    return {
      output: result,
    };
  }
};

export default translateMenusHandler;

This handler follows Payload's job handler pattern. It receives a req object that gives us access to req.payload for database operations and logging. We fetch all menu documents with locale: 'all' to get the complete localized data structure, then process each document individually.

For each document, we identify fields needing translation and process them one by one. If a translation fails, we log the error and continue with the next field rather than stopping the entire job. The 100ms delay between API calls helps avoid rate limiting.

The handler returns an object with an output property containing detailed results: total documents processed, fields successfully translated, and any errors encountered. This output gets stored in Payload's jobs collection for later inspection.

Registering the Job in Payload Config

To make Payload aware of our translation task, register it in the Payload configuration:

// File: payload.config.ts
import translateMenusHandler from '@/payload/jobs/tasks/translateMenus';

export default buildConfig({
  // ... other config options

  jobs: {
    tasks: [
      // ... other tasks
      {
        slug: 'translateMenus',
        label: 'Translate Menu Items (SL → EN/RU)',
        inputSchema: [],
        handler: translateMenusHandler,
      },
    ],
  },

  // ... rest of config
});

The slug is how we'll reference this task when queueing jobs. The label appears in the Payload admin UI if you're viewing jobs manually. Since our handler doesn't need any input parameters, inputSchema is an empty array.

After adding this to your config, you'll need to create a database migration to update the job task enum. Run:

pnpm run payload migrate:create

Payload will generate a migration file that adds translateMenus to the PostgreSQL enum for job task slugs. Then run the migration:

pnpm run payload migrate

This updates your database schema to recognize the new task type.

Creating the HTTP Trigger Endpoint

Finally, create an API route that allows triggering the translation job via HTTP:

// File: src/app/api/translate-menus/route.ts
import { getPayload } from 'payload';
import config from '@payload-config';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  try {
    const authHeader = req.headers.get('authorization');
    if (!authHeader?.startsWith('Bearer ')) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const payload = await getPayload({ config });

    const job = await payload.jobs.queue({
      task: 'translateMenus' as any,
      input: {},
    });

    return NextResponse.json({
      success: true,
      jobId: job.id,
      message: 'Translation job queued successfully',
    });

  } catch (error) {
    console.error('Failed to queue translation job:', error);
    return NextResponse.json(
      {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
      },
      { status: 500 }
    );
  }
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const jobId = searchParams.get('jobId');

  if (!jobId) {
    return NextResponse.json(
      { error: 'jobId query parameter required' },
      { status: 400 }
    );
  }

  try {
    const payload = await getPayload({ config });

    const job = await payload.findByID({
      collection: 'payload-jobs',
      id: jobId,
    });

    return NextResponse.json({
      jobId: job.id,
      status: job.taskStatus,
      result: (job as any).output,
    });

  } catch (error) {
    return NextResponse.json(
      { error: 'Job not found' },
      { status: 404 }
    );
  }
}

The POST endpoint queues a new translation job and returns the job ID. The GET endpoint lets you check a job's status and results by passing the job ID as a query parameter. Basic Bearer token authentication protects both endpoints.

The as any type assertion on the task name is temporary—after restarting your dev server, Payload will regenerate types that include translateMenus in the allowed task names.

Running Your First Translation

Add your OpenAI API key to the .env file:

OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx

Then trigger a translation job:

curl -X POST http://localhost:3000/api/translate-menus \
  -H "Authorization: Bearer YOUR_PAYLOAD_API_KEY" \
  -H "Content-Type: application/json"

This queues the job. To actually execute it, either go to the Payload admin panel under Jobs and click Run, or trigger the job runner endpoint:

curl -X POST http://localhost:3000/api/payload-jobs/run \
  -H "Authorization: Bearer YOUR_CRON_SECRET" \
  -H "Content-Type: application/json"

Watch your server logs to see the translation progress. You'll see messages like "Translating menuItems.0.title to en" followed by "Successfully translated menuItems.0.title to en" for each field.

Once the job completes, check your menu in the Payload admin panel and switch between locales. You should see all the English and Russian translations populated automatically.

Extending This Pattern

This translation system is built for menus, but the pattern works for any Payload collection with localized fields. To adapt it for another collection, you'd need to:

Modify the findEmptyTranslatableFields function to traverse your collection's specific field structure. Instead of menuItems and children, navigate through whatever array or nested structure your collection uses. Adjust the updateSingleField function if your collection has different validation requirements. The placeholder approach we used for customPath might not apply, but the general strategy of fetching, modifying, and saving the document remains the same.

Update the job handler to use a different collection slug when calling req.payload.find() and updateSingleField(). Change the system prompt in translateSingle to match your content domain—product descriptions need different context than menu items.

For pages or blog posts, you might want to translate rich text fields or handle image alt text. The field detection logic would check for Lexical/Slate editor content instead of simple text strings, but the overall flow stays consistent.

What's Next

You now have a working automated translation system that leverages Payload's job queue and OpenAI's structured output. The system handles nested localized fields, works around validation constraints, and provides detailed error reporting.

Some directions you could take this further: Add a "Translate" button in the Payload admin UI that appears on document edit screens. Wire up a Payload collection hook that automatically queues translation when a document is saved with new Slovenian content. Implement a webhook endpoint so external systems can trigger translation. Add support for translating relationship field labels or handling custom field types.

If you're interested in any of these expansions or run into challenges adapting this for your specific use case, let me know in the comments. I'm always happy to help fellow developers solve translation automation problems. Subscribe for more practical Payload CMS guides and Next.js implementation tutorials.

Thanks, Matija

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Table of Contents

  • Understanding the Challenge
  • Architecture Overview
  • Setting Up Dependencies
  • Creating the Translation Schema
  • Building the OpenAI Client
  • Implementing Single Field Translation
  • Detecting Empty Translatable Fields
  • Updating Fields Without Breaking Validation
  • Creating the Payload Job Handler
  • Registering the Job in Payload Config
  • Creating the HTTP Trigger Endpoint
  • Running Your First Translation
  • Extending This Pattern
  • What's Next
On this page:
  • Understanding the Challenge
  • Architecture Overview
  • Setting Up Dependencies
  • Creating the Translation Schema
  • Building the OpenAI Client
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved