Skip to content

Custom Plugins

Custom plugins are an advanced escape hatch. Most apps only need the official plugins: Fetch Loader, Locale Detector, and In-Context Editor.

Use a custom plugin when you need to register a custom loader, listen to i18n events, or add a post-processor.

import type { I18nPluginFactory } from "@comvi/core";
export const MyPlugin: I18nPluginFactory<{ prefix?: string }> = (options = {}) =>
function MyPluginInstance(i18n) {
const prefix = options.prefix ?? "[i18n]";
const unsubscribe = i18n.on("localeChanged", ({ from, to }) => {
console.log(`${prefix} ${from} -> ${to}`);
});
return unsubscribe;
};

Register plugins before init():

const i18n = createI18n({ locale: "en" })
.use(MyPlugin({ prefix: "[app]" }));
await i18n.init();

The plugin function runs during init(). If it returns a function, that function runs during destroy().

registerLoader accepts either a function or a static import map.

Function form — called on demand for any locale/namespace pair:

const ApiLoader: I18nPluginFactory<{ apiUrl: string }> = (options) => {
if (!options?.apiUrl) {
throw new Error("ApiLoader requires apiUrl");
}
return (i18n) => {
i18n.registerLoader(async (locale, namespace) => {
const res = await fetch(`${options.apiUrl}/${locale}/${namespace}.json`);
if (!res.ok) {
throw new Error(`Failed to load ${locale}:${namespace}`);
}
return res.json();
});
};
};

Import-map form — static dynamic imports bundled at build time. Keys are "locale" (shorthand for the default namespace) or "locale:namespace":

const StaticLoader: I18nPluginFactory = () => (i18n) => {
i18n.registerLoader({
en: () => import("./locales/en.json"),
de: () => import("./locales/de.json"),
"en:admin": () => import("./locales/en/admin.json"),
"de:admin": () => import("./locales/de/admin.json"),
});
};

Core resolves the import function for the requested locale:namespace key, falling back to the bare locale key for the default namespace. If no entry matches, it throws.

const AnalyticsPlugin: I18nPluginFactory = () => (i18n) => {
const unsubscribe = i18n.on("missingKey", ({ key, locale, namespace }) => {
analytics.track("i18n_missing_key", { key, locale, namespace });
});
return unsubscribe;
};
const UppercasePlugin: I18nPluginFactory = () => (i18n) => {
i18n.registerPostProcessor((result, key, namespace, params) => {
if (params.raw || typeof result !== "string") return result;
return result.toUpperCase();
});
};

params.raw is only a convention. Core still calls post-processors; each processor decides whether to respect the flag.

Factory errors happen immediately, before .use() receives a plugin:

const RequiredOptionPlugin: I18nPluginFactory<{ apiKey: string }> = (options) => {
if (!options?.apiKey) {
throw new Error("apiKey is required");
}
return (i18n) => {
// ...
};
};

Plugin-function errors happen during init() and are controlled by PluginOptions:

i18n.use(MyPlugin(), {
required: false,
timeout: 5000,
onError: (error) => report(error),
});
  • Plugins run in registration order during init().
  • The registered locale detector runs after all plugins and before initial namespace loading.
  • Cleanup functions run in reverse order during destroy().
  • Only one loader and one locale detector are active at a time; registering another replaces the previous one.

Plugins can subscribe to any of the following events via i18n.on(event, handler). See Error Handling for payload shapes and error-handling patterns.

EventFires when
initializedinit() completed successfully
destroyeddestroy() was called
localeChangedActive locale changed — payload { from, to }
defaultNamespaceChangedDefault namespace changed — payload { from, to }
translationsClearedCache cleared — payload { locale?, namespace? }
loadingStateChangedLoading state changed — payload { isLoading, isInitializing }
namespaceLoadedA namespace finished loading — payload { namespace, locale }
missingKeyt() could not find a key — payload { key, locale, namespace }
loadErrorA namespace loader failed — payload { locale, namespace, error }