strapi-provider-translate-custom-api
v2.3.0
Published
Custom HTTP-endpoint translation provider for strapi-plugin-translate. Route translation requests through any URL you control instead of being locked into DeepL/Google/ChatGPT.
Maintainers
Readme
strapi-provider-translate-custom-api
A translation provider for strapi-plugin-translate that routes translation requests to any HTTP endpoint you control instead of a fixed third party (DeepL, Google, ChatGPT). You write the translation server; this provider handles the wire protocol.
⚠️ v2.0.0 contains breaking wire-contract changes. If you are upgrading from v1.x, see the Migration from v1.x section below before deploying. v1.x consumers must update their custom API server to read auth from a header and parse Content-Type before they can install v2.0.0.
Features
- Bring-your-own translation endpoint — point at any URL that accepts POST and returns plain text.
- HTML auto-detection — input is sniffed via
is-html; HTML payloads are flagged on the wire so your server can handle them differently from plain text. - Strapi blocks (jsonb) round-trip — block editor content is converted to HTML for translation and back to blocks afterwards.
- Locale fallbacks — built-in fallback table for providers that don't support specific locales (e.g. DeepL doesn't support
es-419→ falls back toes). - Per-item resilience — when one item in a batch fails, the source text is returned for that slot and the rest of the batch still succeeds. If every item fails, the batch throws an
AggregateErrorso the host plugin sees the failure instead of silently presenting source-text fallbacks. - Concurrency control — batched fan-out is throttled (default 5 in flight) so a large page doesn't fire dozens of simultaneous POSTs at your translation backend. Configurable via
providerOptions.concurrency. - Markdown round-tripping — markdown fields are converted to HTML before sending and back to markdown after, so your custom API only ever sees plain text or HTML on the wire (never raw markdown semantics).
Installation
npm install strapi-provider-translate-custom-apiConfiguration
Configure in config/plugins.js after installing strapi-plugin-translate:
module.exports = ({ env }) => ({
translate: {
enabled: true,
config: {
provider: "custom-api",
providerOptions: {
apiURL: env("TRANSLATION_API_URL"), // required
apiKey: env("TRANSLATION_API_KEY"), // optional; sent as Bearer token
translationProvider: "MyProvider", // optional label, see fallback table
timeoutMs: 30_000, // optional, default 30s
concurrency: 5, // optional, default 5
},
translatedFieldTypes: [
"string",
{ type: "blocks", format: "jsonb" },
{ type: "text", format: "plain" },
{ type: "richtext", format: "markdown" },
"component",
"dynamiczone",
],
},
},
});providerOptions
| Option | Type | Default | Description |
| --------------------- | ------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apiURL | string | — (required) | POST endpoint for translations. Validated at init time via new URL(...). |
| apiKey | string | undefined | Sent as Authorization: Bearer <apiKey> when set. |
| translationProvider | string | undefined | Forwarded as ?provider=... and used to key the locale fallback table. |
| timeoutMs | number | 30_000 | Per-request timeout. Hanging endpoints abort after this many milliseconds. |
| concurrency | number | 5 | Max in-flight requests when translating a batch. Lower it if your translation backend rate-limits aggressively; raise it if your backend is fast and you have plenty of capacity. |
Custom API server contract
This section is the spec for the HTTP server you put behind apiURL. It covers query parameters, the HTTP method, status-code semantics, the response body shape, and a minimal Express implementation. It also doubles as the v2.0.0 wire contract — the rules below are what the provider has spoken on the wire since v2.0.0.
The provider issues one POST per item in the batch.
Request
POST {apiURL}?target={targetLocale}&source={sourceLocale}[&format=html][&provider={translationProvider}]
Headers:
Content-Type: text/plain (or text/html when the body is HTML)
Authorization: Bearer <apiKey> (only when apiKey is configured)
Body: the raw text or HTML to translate- Query parameters are encoded via
URLSearchParams. Locale codes, provider names, and any other interpolated values are properly percent-encoded. format=htmlis added when the input passesis-html(). Plain text omits the parameter.- The request aborts after
timeoutMs(default 30s) viaAbortSignal.timeout(...). - The provider runs at most
concurrencyitems in flight at once (default 5) — large pages no longer fire 50+ simultaneous POSTs at your backend.
Per-format behavior
| Field type / format | What hits the wire |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| string, text, plain | The raw text. Content-Type: text/plain. |
| html (input is already HTML) | The HTML. Content-Type: text/html, &format=html. |
| markdown | Converted to HTML before sending and back to markdown after. Content-Type: text/html, &format=html. Your custom API never sees raw markdown. |
| jsonb (Strapi blocks) | Blocks → HTML (via the host plugin's format service) → POST → HTML response → blocks. Content-Type: text/html, &format=html. |
Response
The response body must be the translated string in the body — plain text, no JSON envelope. The provider reads the body via response.text() and uses it directly as the translation.
| Status | Meaning | Provider behavior |
| -------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2xx with non-empty body | Success. Body is the translated text. | Used as-is for the slot. |
| 2xx with empty body | Treated as failure. | Throws inside fetchTranslation; the slot falls back to source text and a warning is logged. If every item fails, the batch throws AggregateError. |
| Any non-2xx (4xx, 5xx) | Failure. The HTTP status is included in the thrown error. | Same as empty body — per-item fallback to source text + warning, or batch-level AggregateError. |
Authentication errors should use 401/403 and return any human-readable message in the body. Validation failures (unsupported locale, missing field) should use 4xx. Upstream translation backend errors should use 5xx. The provider does not distinguish between these on the wire — they all funnel into the same fallback path — but accurate status codes show up in the warning logs and make operator debugging far easier.
Example custom API server (Express, v2.0.0)
import express from "express";
import { translate } from "your-translation-engine";
const app = express();
app.use(express.text({ type: ["text/plain", "text/html"] }));
app.post("/translate", async (req, res) => {
const apiKey = req.headers.authorization?.replace(/^Bearer /, "");
if (apiKey !== process.env.MY_API_KEY) return res.sendStatus(401);
const { target, source, format } = req.query;
const isHTML = format === "html";
const translated = await translate(req.body, { target, source, isHTML });
res.type(isHTML ? "text/html" : "text/plain").send(translated);
});
app.listen(3000);Failure behavior
Translation failures are handled at two distinct layers — per-item and per-batch — and the provider deliberately reports them differently.
Per-item: silent fallback to source text (since v2.1.0)
When a single item in a batch fails (non-2xx response, network error, timeout, empty response body), the provider:
- Catches the error inside
allSettledLimit. - Logs a warning via
strapi.log.warnwith the prefix[strapi-provider-translate-custom-api], the array index of the failed item, the original source text, and the error message. - Substitutes the original source text into that slot of the result array so the rest of the batch still completes.
This means a content editor saving a page where one field couldn't be translated will see source-language text in that field — not an error, not an empty string. The batch returns 2xx to the host plugin and the editor can manually retranslate the affected field. The strapi.log.warn route (added in v2.1.0, #10) flows through Strapi's pino logger, so failures respect your project's configured log level and format and can be surfaced in dashboards alongside the rest of your Strapi logs.
Before v2.1.0 these failures were emitted via console.error. Before v2.0.0 they were silently swallowed entirely.
Batch-level: throws AggregateError when every item fails (since v2.0.0)
If every item in the batch fails (e.g. your custom API is down, the API key is wrong, the URL is misrouted), the provider throws an AggregateError whose errors field contains the per-item rejection reasons. The host plugin sees the error and surfaces it to the editor instead of silently presenting an entire page of source-text fallbacks that look like successful translations.
This was introduced in v2.0.0 (#8) to fix the v1.x behavior where catastrophic failures were indistinguishable from successful translations.
No silent-fallback opt-out today
There is no silentFallback: false flag that converts the per-item warning into a thrown error. If you need that behavior — for example, you'd rather have the whole batch fail loudly than ship source-text fallbacks to your editors — open an issue describing the use case. It would land as a new entry in providerOptions in a future minor release; this PR documents the current behavior only.
Migration from v1.x
If you have a custom API server speaking the v1.x contract, you need to update it before installing v2.0.0. The differences:
| Concern | v1.x | v2.0.0 |
| ---------------------- | -------------------------------------------------- | ------------------------------------------------------------------ |
| API key location | ?apiKey=... query param | Authorization: Bearer <key> header |
| Query encoding | Raw template-string interpolation | URLSearchParams (proper percent-encoding) |
| Content-Type on POST | Not set | text/plain or text/html |
| Timeout | None (could hang forever) | 30s default, configurable via timeoutMs |
| Failures | Silently returned source text and reported success | Per-item: log + source-text fallback. Batch-level all-fail: throws |
Server-side migration example
Before (v1.x):
app.post("/translate", async (req, res) => {
if (req.query.apiKey !== process.env.MY_API_KEY) return res.sendStatus(401);
const { target, source, format } = req.query;
const text = req.body; // assumed string from raw-body parsing
const translated = await translate(text, { target, source });
res.send(translated);
});After (v2.0.0):
app.use(express.text({ type: ["text/plain", "text/html"] })); // honor Content-Type
app.post("/translate", async (req, res) => {
const key = req.headers.authorization?.replace(/^Bearer /, "");
if (key !== process.env.MY_API_KEY) return res.sendStatus(401);
const { target, source, format } = req.query;
const translated = await translate(req.body, {
target,
source,
isHTML: format === "html",
});
res.type(req.headers["content-type"]).send(translated);
});Compatibility
- Strapi 4 and Strapi 5. Works with
strapi-plugin-translatev1.x (Strapi 4) and its v2.x line (Strapi 5) — the same provider build supports both. - Declared as a
peerDependencyonstrapi-plugin-translate ^1.4.0 || ^2.0.0-next.4. npm will warn if you install this provider against an incompatible host plugin version. The provider depends only on the host'sformatservice surface —format.blockToHtml,format.htmlToBlock,format.markdownToHtml,format.htmlToMarkdown— plus the provider contract (init() → { translate, usage }). Both are identical in the plugin's v1.x and v2.x lines (verified against[email protected]), so no code changes were needed for Strapi 5. - The plugin's Strapi 5 line (
2.0.0-next.x) is currently a prerelease — pin it and run an end-to-end translate check before relying on it in production.
License
MIT — see LICENSE.
