✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem16 min read

How to Build a Translation Memory and Localization Workflow Tool with Strapi 5

July 29, 2026
How to Build a Translation Memory and Localization Workflow Tool with Strapi 5

Translation memory (TM) is a database of previously translated text segments stored as bilingual pairs. Every time a translator confirms a sentence, that source-target pair gets saved.

The next time similar text appears, the system retrieves the stored translation and scores it by similarity. Without a TM, translators often have to re-translate identical or near-identical sentences across projects instead of reusing previously translated segments, reducing efficiency.

In this article, you will build a localization workflow backend with Strapi 5. Strapi v5's built-in i18n support and the Document Service API (both accessible via its REST API) make it a strong fit for building a tool that can store translation units, perform fuzzy matching, and automate translation capture using lifecycle hooks, but these features require custom implementation and are not provided by Strapi out of the box.

In brief:

  • Build localized Content-Types with i18n field configuration in Strapi v5
  • Implement a custom fuzzy matching service using Levenshtein distance for translation memory lookups
  • Use lifecycle hooks to automatically capture translation pairs when localized content is created or updated
  • Query results through Strapi v5's REST API with filters, sorting, and pagination

Prerequisites

RequirementVersion
Node.jsv24 LTS (v20 or v22 LTS also supported)
npmv11.12.1 (bundled with Node 24; v6+ minimum)
Strapiv5.47.0
DatabaseSQLite (default) or PostgreSQL 14+

You need working knowledge of JavaScript/TypeScript, REST APIs, and basic content modeling. No third-party API keys are required. You can also browse the Strapi Marketplace for community plugins that extend localization capabilities.

Odd-numbered Node.js releases (v23, v25) are not supported by Strapi.

Setting Up the Strapi Backend

Step 1: Scaffold the Project

Run the Strapi CLI to create a new TypeScript project. The --skip-cloud flag skips the Strapi Cloud login step, --no-run stops the app from starting automatically, and SQLite is used as the default database.

npx create-strapi@latest translation-memory-tool --typescript --skip-cloud --no-run

Move into the project directory:

cd translation-memory-tool

Your project structure looks like this:

translation-memory-tool/
├── config/
├── database/
├── src/
│   ├── admin/
│   ├── api/           ← content types, controllers, services, routes
│   ├── components/
│   └── index.ts       ← register(), bootstrap(), destroy()
├── .env
├── package.json
└── tsconfig.json

Step 2: Define the Translation Memory Content-Type

A translation memory groups translation units by language pair. Create the directory structure and schema file:

mkdir -p src/api/translation-memory/content-types/translation-memory
mkdir -p src/api/translation-memory/controllers
mkdir -p src/api/translation-memory/services
mkdir -p src/api/translation-memory/routes
// src/api/translation-memory/content-types/translation-memory/schema.json
{
  "kind": "collectionType",
  "collectionName": "translation_memories",
  "info": {
    "displayName": "Translation Memory",
    "singularName": "translation-memory",
    "pluralName": "translation-memories",
    "description": "Groups translation units by language pair"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {},
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "sourceLocale": {
      "type": "string",
      "required": true
    },
    "targetLocale": {
      "type": "string",
      "required": true
    },
    "units": {
      "type": "relation",
      "relation": "oneToMany",
      "target": "api::translation-unit.translation-unit",
      "mappedBy": "translationMemory"
    }
  }
}

Add the core controller, core service, and route files so Strapi registers this API:

// src/api/translation-memory/controllers/translation-memory.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::translation-memory.translation-memory');
// src/api/translation-memory/services/translation-memory.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::translation-memory.translation-memory');
// src/api/translation-memory/routes/translation-memory.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::translation-memory.translation-memory');

Step 3: Define the Translation Unit Content-Type

Each translation unit stores a source/target text pair, while match scores may be used when retrieving similar segments. This is the core data model, mirroring the TMX specification's <tu> element structure.

mkdir -p src/api/translation-unit/content-types/translation-unit
mkdir -p src/api/translation-unit/controllers
mkdir -p src/api/translation-unit/services
mkdir -p src/api/translation-unit/routes
// src/api/translation-unit/content-types/translation-unit/schema.json
{
  "kind": "collectionType",
  "collectionName": "translation_units",
  "info": {
    "displayName": "Translation Unit",
    "singularName": "translation-unit",
    "pluralName": "translation-units",
    "description": "Stores source/target segment pairs for translation memory"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {},
  "attributes": {
    "sourceText": {
      "type": "text",
      "required": true
    },
    "targetText": {
      "type": "text",
      "required": true
    },
    "sourceLocale": {
      "type": "string",
      "required": true
    },
    "targetLocale": {
      "type": "string",
      "required": true
    },
    "matchScore": {
      "type": "integer",
      "min": 0,
      "max": 102
    },
    "status": {
      "type": "enumeration",
      "enum": ["pending", "approved", "rejected", "auto"],
      "default": "pending",
      "required": true
    },
    "usageCount": {
      "type": "integer",
      "default": 0
    },
    "contextBefore": {
      "type": "text"
    },
    "contextAfter": {
      "type": "text"
    },
    "documentRef": {
      "type": "string"
    },
    "translationMemory": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::translation-memory.translation-memory",
      "inversedBy": "units"
    }
  }
}

The matchScore field stores percentage values up to 102, following memoQ's convention where 100% means exact match, 101% indicates a context match (identical text plus identical surrounding segments or ID-based context), and 102% represents a double-context match (both surrounding text and ID-based context).

Step 4: Define a Localized Article Content-Type

This Content-Type represents the content being translated. It uses Strapi v5's built-in i18n configuration at both the model and field level. For a comprehensive walkthrough, the Strapi 5 i18n guide covers the implementation details.

mkdir -p src/api/article/content-types/article
mkdir -p src/api/article/controllers
mkdir -p src/api/article/services
mkdir -p src/api/article/routes
// src/api/article/content-types/article/schema.json
{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": {
    "displayName": "Article",
    "singularName": "article",
    "pluralName": "articles"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {
    "i18n": {
      "localized": true
    }
  },
  "attributes": {
    "title": {
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      },
      "type": "string",
      "required": true
    },
    "slug": {
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      },
      "type": "uid",
      "targetField": "title",
      "required": true
    },
    "content": {
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      },
      "type": "richtext"
    }
  }
}

In Strapi v5, i18n is part of the core. You do not need @strapi/plugin-i18n as a dependency. Fields without pluginOptions.i18n.localized: true share the same value across all locales. For best practices on i18n implementation, that guide covers common patterns.

Add the core files for the article API:

// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::article.article');
// src/api/article/services/article.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::article.article');
// src/api/article/routes/article.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::article.article');

Step 5: Start Strapi and Create an Admin User

Build and start the development server:

npm run build
npm run develop

Open http://localhost:1337/admin in your browser. Create your first admin user when prompted. After logging in, add your target locales through Settings > Global Settings > Internationalization (for example, fr for French and es for Spanish).

Step 6: Build the Levenshtein Fuzzy Matching Service

Edit distance (Levenshtein distance) is a widely used basis for fuzzy matching algorithms in many TM systems. A common normalized similarity formula is: 1 - EditDistance(s, t) / max(|s|, |t|).

Create the custom service that performs TM lookups. This service uses a two-stage retrieval pattern: first a database-level candidate filter using $containsi, then precise Levenshtein scoring in JavaScript against the narrowed candidate set.

// src/api/translation-unit/services/translation-unit.ts
import { factories } from '@strapi/strapi';

function levenshtein(a: string, b: string): number {
  const matrix: number[][] = [];
  for (let i = 0; i <= b.length; i++) {
    matrix[i] = [i];
  }
  for (let j = 0; j <= a.length; j++) {
    matrix[0][j] = j;
  }
  for (let i = 1; i <= b.length; i++) {
    for (let j = 1; j <= a.length; j++) {
      if (b.charAt(i - 1) === a.charAt(j - 1)) {
        matrix[i][j] = matrix[i - 1][j - 1];
      } else {
        matrix[i][j] = Math.min(
          matrix[i - 1][j - 1] + 1,
          matrix[i][j - 1] + 1,
          matrix[i - 1][j] + 1
        );
      }
    }
  }
  return matrix[b.length][a.length];
}

function computeMatchScore(source: string, candidate: string): number {
  const distance = levenshtein(source.toLowerCase(), candidate.toLowerCase());
  const maxLen = Math.max(source.length, candidate.length);
  if (maxLen === 0) return 100;
  return Math.round((1 - distance / maxLen) * 100);
}

export default factories.createCoreService(
  'api::translation-unit.translation-unit',
  ({ strapi }) => ({
    async findMatches(
      sourceText: string,
      sourceLocale: string,
      targetLocale: string,
      threshold: number = 70
    ) {
      const words = sourceText.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
      const filterWord = words[0] || sourceText.substring(0, 10);

      const candidates = await strapi
        .documents('api::translation-unit.translation-unit')
        .findMany({
          filters: {
            sourceLocale: { $eq: sourceLocale },
            targetLocale: { $eq: targetLocale },
            sourceText: { $containsi: filterWord },
            status: { $eq: 'approved' },
          },
          populate: { translationMemory: true },
        });

      const scored = candidates
        .map((unit) => ({
          documentId: unit.documentId,
          sourceText: unit.sourceText,
          targetText: unit.targetText,
          matchScore: computeMatchScore(sourceText, unit.sourceText),
          usageCount: unit.usageCount,
          translationMemory: unit.translationMemory,
        }))
        .filter((unit) => unit.matchScore >= threshold)
        .sort((a, b) => b.matchScore - a.matchScore);

      return scored;
    },

    async recordUsage(documentId: string) {
      const existing = await strapi
        .documents('api::translation-unit.translation-unit')
        .findOne({ documentId });

      if (existing) {
        await strapi
          .documents('api::translation-unit.translation-unit')
          .update({
            documentId,
            data: {
              usageCount: (existing.usageCount || 0) + 1,
            },
          });
      }
    },
  })
);

Step 7: Create the Custom Controller

The controller exposes two custom actions: lookup for finding TM matches against a source segment, and stats for basic TM statistics.

// src/api/translation-unit/controllers/translation-unit.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController(
  'api::translation-unit.translation-unit',
  ({ strapi }) => ({
    async lookup(ctx) {
      const { sourceText, sourceLocale, targetLocale, threshold } = ctx.request.body;

      if (!sourceText || !sourceLocale || !targetLocale) {
        return ctx.badRequest('sourceText, sourceLocale, and targetLocale are required.');
      }

      const sanitizedSourceText = sourceText.trim().substring(0, 5000);

      const matches = await strapi
        .service('api::translation-unit.translation-unit')
        .findMatches(sanitizedSourceText, sourceLocale, targetLocale, threshold || 70);

      ctx.body = {
        data: matches,
        meta: { total: matches.length, threshold: threshold || 70 },
      };
    },

    async stats(ctx) {
      const totalUnits = await strapi
        .documents('api::translation-unit.translation-unit')
        .count({});

      const approvedUnits = await strapi
        .documents('api::translation-unit.translation-unit')
        .count({ filters: { status: { $eq: 'approved' } } });

      const pendingUnits = await strapi
        .documents('api::translation-unit.translation-unit')
        .count({ filters: { status: { $eq: 'pending' } } });

      ctx.body = {
        data: {
          total: totalUnits,
          approved: approvedUnits,
          pending: pendingUnits,
        },
      };
    },
  })
);

Step 8: Register the Custom Routes

Strapi needs explicit route definitions for custom actions. Create a separate route file for your custom endpoints alongside the core router.

// src/api/translation-unit/routes/translation-unit.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::translation-unit.translation-unit');
// src/api/translation-unit/routes/01-custom-routes.ts
export default {
  routes: [
    {
      method: 'POST',
      path: '/translation-units/lookup',
      handler: 'api::translation-unit.translation-unit.lookup',
      config: {
        policies: [],
        middlewares: [],
      },
    },
    {
      method: 'GET',
      path: '/translation-units/stats',
      handler: 'api::translation-unit.translation-unit.stats',
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};

Step 9: Add Lifecycle Hooks for Automatic TM Population

When a localized article gets updated (a translator confirms the French version, for example), a lifecycle hook automatically captures that source/target pair and writes it to the TM. Register this in src/index.ts using the programmatic bootstrap approach so the strapi instance is accessible. For background on when to use lifecycle hooks, that guide covers the decision points.

// src/index.ts
export default {
  register({ strapi }) {},

  async bootstrap({ strapi }) {
    strapi.db.lifecycles.subscribe({
      models: ['api::article.article'],

      async afterCreate(event) {
        const { result } = event;

        if (result.publishedAt === null) return;

        if (result.locale && result.locale !== 'en' && result.title) {
          try {
            const englishDoc = await strapi
              .documents('api::article.article')
              .findOne({
                documentId: result.documentId,
                locale: 'en',
                status: 'published',
              });

            if (englishDoc && englishDoc.title) {
              await strapi
                .documents('api::translation-unit.translation-unit')
                .create({
                  data: {
                    sourceText: englishDoc.title,
                    targetText: result.title,
                    sourceLocale: 'en',
                    targetLocale: result.locale,
                    matchScore: 100,
                    status: 'pending',
                    documentRef: result.documentId,
                  },
                });
              strapi.log.info(
                `TM entry captured: "${englishDoc.title}" → "${result.title}" [en→${result.locale}]`
              );
            }
          } catch (err) {
            strapi.log.error('Failed to capture TM segment:', err);
          }
        }
      },

      async afterUpdate(event) {
        const { result } = event;

        if (result.locale && result.locale !== 'en' && result.title) {
          try {
            const existing = await strapi
              .documents('api::translation-unit.translation-unit')
              .findMany({
                filters: {
                  documentRef: { $eq: result.documentId },
                  targetLocale: { $eq: result.locale },
                },
              });

            if (existing.length > 0) {
              await strapi
                .documents('api::translation-unit.translation-unit')
                .update({
                  documentId: existing[0].documentId,
                  data: {
                    targetText: result.title,
                    status: 'approved',
                  },
                });
            } else {
              const englishDoc = await strapi
                .documents('api::article.article')
                .findOne({
                  documentId: result.documentId,
                  locale: 'en',
                  status: 'published',
                });

              if (englishDoc && englishDoc.title) {
                await strapi
                  .documents('api::translation-unit.translation-unit')
                  .create({
                    data: {
                      sourceText: englishDoc.title,
                      targetText: result.title,
                      sourceLocale: 'en',
                      targetLocale: result.locale,
                      matchScore: 100,
                      status: 'approved',
                      documentRef: result.documentId,
                    },
                  });
              }
            }
          } catch (err) {
            strapi.log.error('Failed to update TM segment:', err);
          }
        }
      },
    });
  },

  async destroy({ strapi }) {},
};

In Strapi v5, a single Document Service call can trigger multiple database lifecycle hooks. A create() with status: 'published' can trigger beforeCreate/afterCreate multiple times because of Strapi v5's draft/published handling.

The afterCreate guard checking result.publishedAt === null can skip draft-version hook invocations and help avoid duplicate TM entries when Strapi fires hooks for both draft and published records. For more on how Document Service middleware differs from lifecycle hooks, that guide explains the tradeoffs.

Building the REST API Client

The TM backend exposes standard REST endpoints. Any HTTP client can consume them, whether that's a browser-based fetch call, a React component, a Next.js API route, or a standalone Node.js script. The examples below use vanilla JavaScript so the patterns transfer to any framework. Each example authenticates with a Strapi API token read from an environment variable.

Step 1: Query TM Matches from a Client

This function posts a source segment to the lookup endpoint and returns scored matches. A translator-facing UI would call this each time a user selects a new segment for translation.

// client/lookup.js
const STRAPI_URL = 'http://localhost:1337';
const API_TOKEN = process.env.STRAPI_API_TOKEN;

async function lookupTranslation(sourceText, sourceLocale, targetLocale, threshold) {
  const response = await fetch(`${STRAPI_URL}/api/translation-units/lookup`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_TOKEN}`,
    },
    body: JSON.stringify({
      sourceText,
      sourceLocale,
      targetLocale,
      threshold: threshold || 70,
    }),
  });

  if (!response.ok) {
    throw new Error(`Lookup failed: ${response.status} ${response.statusText}`);
  }

  const result = await response.json();

  for (const match of result.data) {
    console.log(`[${match.matchScore}%] ${match.sourceText}`);
    console.log(`   → ${match.targetText}`);
  }

  return result;
}

lookupTranslation(
  'The application crashed suddenly',
  'en',
  'fr',
  60
);

The response shape mirrors the controller output: data contains an array of scored matches, and meta includes the total count and the threshold that was applied.

Step 2: Record Usage and Update Translation Units

When a translator accepts a TM suggestion, the suggested translation is applied in the editor. These are separate API calls to the standard REST endpoints. The recordUsage function updates usage data via an API request.

// client/usage.js
const STRAPI_URL = 'http://localhost:1337';
const API_TOKEN = process.env.STRAPI_API_TOKEN;

async function approveTranslationUnit(documentId) {
  const response = await fetch(`${STRAPI_URL}/api/translation-units/${documentId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_TOKEN}`,
    },
    body: JSON.stringify({
      data: {
        status: 'approved',
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Update failed: ${response.status}`);
  }

  return response.json();
}

async function recordUsage(documentId) {
  const getResponse = await fetch(`${STRAPI_URL}/api/translation-units/${documentId}`, {
    headers: {
      'Authorization': `Bearer ${API_TOKEN}`,
    },
  });
  if (!getResponse.ok) {
    throw new Error(`Fetch failed: ${getResponse.status}`);
  }
  const current = await getResponse.json();
  const currentCount = current.data.usageCount || 0;

  const response = await fetch(`${STRAPI_URL}/api/translation-units/${documentId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_TOKEN}`,
    },
    body: JSON.stringify({
      data: {
        usageCount: currentCount + 1,
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Usage update failed: ${response.status}`);
  }

  return response.json();
}

async function fetchStats() {
  const response = await fetch(`${STRAPI_URL}/api/translation-units/stats`, {
    headers: {
      'Authorization': `Bearer ${API_TOKEN}`,
    },
  });

  if (!response.ok) {
    throw new Error(`Stats request failed: ${response.status}`);
  }

  const result = await response.json();
  console.log(`Total: ${result.data.total}`);
  console.log(`Approved: ${result.data.approved}`);
  console.log(`Pending: ${result.data.pending}`);

  return result;
}

These functions can be called from a React useEffect hook, a Next.js server action, or a CLI tool, but the execution environment differs between those contexts. The Strapi REST API is framework-agnostic.

Step 3: Handle Pagination and Filtering

Production TM databases grow to thousands of entries. Use Strapi v5's built-in query parameters to paginate results and filter by status or locale. For details on how populate and filtering work, that guide covers the patterns.

// client/browse.js
const STRAPI_URL = 'http://localhost:1337';
const API_TOKEN = process.env.STRAPI_API_TOKEN;

async function browseApprovedUnits(page, pageSize) {
  const params = new URLSearchParams({
    'pagination[page]': String(page || 1),
    'pagination[pageSize]': String(pageSize || 25),
    'filters[status][$eq]': 'approved',
    'sort': 'matchScore:desc',
    'populate': 'translationMemory',
  });

  const response = await fetch(`${STRAPI_URL}/api/translation-units?${params}`, {
    headers: {
      'Authorization': `Bearer ${API_TOKEN}`,
    },
  });

  if (!response.ok) {
    throw new Error(`Browse failed: ${response.status}`);
  }

  const result = await response.json();
  console.log(`Page ${result.meta.pagination.page} of ${result.meta.pagination.pageCount}`);

  for (const unit of result.data) {
    console.log(`[${unit.matchScore}%] ${unit.sourceText} → ${unit.targetText}`);
  }

  return result;
}

browseApprovedUnits(1, 10);

The filters, sort, and pagination parameters use LHS bracket syntax as documented in the Strapi REST API reference. For complex nested queries, the qs library can generate these parameter strings from JavaScript objects.

Putting It All Together

Step 1: Rebuild and Restart

Stop the dev server if it's running, then rebuild:

npm run build
npm run develop

Step 2: Configure API Permissions

In the Admin Panel, go to Settings > Users and Permissions Plugin > Roles > Public. Enable these permissions:

  • Translation-memory: find, findOne
  • Translation-unit: find, findOne, create, update, delete
  • Article: find, findOne, create, update, delete

For the custom routes, you also need to enable the lookup and stats actions under Translation-unit.

For authenticated client access, create an API token through Settings > API Tokens > Create new API Token. Set the token type to Full access, Read-only, or Custom, and configure Custom permissions to match the public role above if needed. Store the token in an environment variable (STRAPI_API_TOKEN) and pass it as a Bearer token in the Authorization header. The client examples in this tutorial read from process.env.STRAPI_API_TOKEN.

Step 3: Seed TM Data and Test the Lookup

Create a translation memory and a few translation units:

curl -X POST http://localhost:1337/api/translation-memories \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "name": "Product UI Strings",
      "sourceLocale": "en",
      "targetLocale": "fr"
    }
  }'

Note the documentId from the response, then create translation units (replace YOUR_TM_DOC_ID with the actual value):

curl -X POST http://localhost:1337/api/translation-units \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "sourceText": "The application has crashed unexpectedly",
      "targetText": "L'\''application s'\''est arrêtée de manière inattendue",
      "sourceLocale": "en",
      "targetLocale": "fr",
      "matchScore": 100,
      "status": "approved",
      "translationMemory": "YOUR_TM_DOC_ID"
    }
  }'

curl -X POST http://localhost:1337/api/translation-units \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "sourceText": "Click OK to continue",
      "targetText": "Cliquez sur OK pour continuer",
      "sourceLocale": "en",
      "targetLocale": "fr",
      "matchScore": 100,
      "status": "approved",
      "translationMemory": "YOUR_TM_DOC_ID"
    }
  }'

Test the fuzzy lookup. A query for "The application has crashed" should return a similar or top match, such as "The application has crashed unexpectedly." A query for "The application crashed suddenly" should return a fuzzy match with a score calculated from edit distance, depending on the length and character differences between the strings:

curl -X POST http://localhost:1337/api/translation-units/lookup \
  -H "Content-Type: application/json" \
  -d '{
    "sourceText": "The application crashed suddenly",
    "sourceLocale": "en",
    "targetLocale": "fr",
    "threshold": 60
  }'

Expected response:

{
  "data": [
    {
      "documentId": "abc123...",
      "sourceText": "The application has crashed unexpectedly",
      "targetText": "L'application s'est arrêtée de manière inattendue",
      "matchScore": 68,
      "usageCount": 0,
      "translationMemory": { "documentId": "...", "name": "Product UI Strings" }
    }
  ],
  "meta": { "total": 1, "threshold": 60 }
}

Check TM statistics:

curl http://localhost:1337/api/translation-units/stats

Step 4: Test Automatic TM Capture

Create an English article, then add its French locale version.

curl -X POST "http://localhost:1337/api/articles?locale=en" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "title": "Getting Started Guide",
      "slug": "getting-started-guide",
      "content": "Welcome to our platform."
    }
  }'

Note the documentId from the response, then create the French version:

curl -X PUT "http://localhost:1337/api/articles/YOUR_DOC_ID?locale=fr" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "title": "Guide de démarrage",
      "slug": "guide-de-demarrage",
      "content": "Bienvenue sur notre plateforme."
    }
  }'

Check your server logs for relevant localization or TM-related messages. Query the translation units to verify the new entry exists:

curl "http://localhost:1337/api/translation-units?filters[documentRef][$eq]=YOUR_DOC_ID&populate=translationMemory"

Next Steps

  • Deploy to production: Follow the Strapi deployment docs to move your TM tool to Strapi Cloud or a self-hosted environment with PostgreSQL, which Strapi recommends for production deployments. The deployment guide covers hosting options.
  • Add content-level TM matching: Extend the findMatches service to segment richtext content into sentences before lookup, following SRX segmentation rules for consistency across tools.
  • Add TMX import/export: Parse and generate TMX 1.4b XML files so your TM database can exchange data with tools like memoQ or SDL Trados.
  • Build a review workflow: Use Strapi's Review Workflows to create stages like "Untranslated → TM Matched → Translator Review → Approved" for structured localization oversight.
  • Add a frontend dashboard: Connect a React or Next.js application to the REST API to provide a translation interface. For broader localization strategy, the content localization guide covers planning and architecture.

Paul BratslavskyDeveloper Advocate
How to Build Your Personal Library and Book Catalog System Using Strapi 5
Ecosystem·15 min read

How to Build Your Personal Library and Book Catalog System Using Strapi 5

Build a personal library catalog with Strapi 5. Define collection types, seed data via Document Service API, and display books in a vanilla JS frontend.

July 29, 2026
Build an e-Signature Platform with Strapi 5 and React
Ecosystem·16 min read

How to Build an e-Signature Platform with Strapi 5 and React

Build a working e-signature platform with Strapi 5 and React. Capture signatures on canvas, upload via REST API, and track document status end-to-end.

·July 29, 2026
Express.js Explained: A Beginner's Guide to Node.js Web Framework Basics
Ecosystem·16 min read

Express.js Explained: A Beginner's Guide to Node.js Web Framework Basics

Learn how Express.js works, what it gives you over raw Node.js, and where it fits in 2026. Covers routing, middleware, async error handling, and Express 5.

July 29, 2026