Skip to content
Comvi

Render on the server

View .md

Render translated text into the server response so crawlers, link previews and the first paint see real copy. One question decides the shape of every setup: may the instance live at module scope on the server? Each section takes the instance from the Quick start and one of @comvi/next, @comvi/nuxt, @comvi/svelte, @comvi/solid or @comvi/vue.

Framework Instance at module scope on the server How the request locale arrives
Next.js required: the config module withComvi points at is one instance per process, and no request writes the locale the [locale] param, passed to getI18n({ locale }) and <ComviProvider locale>
Nuxt forbidden: the module builds a fresh instance per request from the comvi.i18n.ts factory the module’s resolved locale, per request
SvelteKit forbidden: a per-request instance built in +layout.svelte from load data data.locale from load
SolidStart forbidden: adopting ssrLocale writes i18n.locale inside renderToString ssrLocale on <I18nProvider>
Vue forbidden: a module-scope instance is a singleton whose locale ref moves under concurrent renders ssrLocale in the constructor options
React either, when nothing writes the locale; the framework around React sets the rule ssrLocale on the provider

A server process handles concurrent requests and the instance holds a mutable locale, so a shared instance is safe only where no request writes it. Next.js is that case; every other framework builds an instance per request.

Two React provider props make a server render and its hydration agree. ssrLocale is the locale the server snapshot renders in. ssrInitialIsInitializing decides whether fallback gates the server render and defaults to false, so a page with a catalog paints its children on both sides. Pass ssrInitialIsInitializing={true} with fallback only when the server had no catalog to paint from. Solid has no snapshot: renderToString paints the fallback, so pass none when the catalog is inline. Svelte and Vue gate on isInitializing in markup.

@comvi/next does the per-request work: getI18n({ locale }) translates against the locale you pass, and <ComviProvider locale> loads the catalog on the server and seeds the client cache with it before children render. Only the catalog travels between the two bundles, as plain messages, and only the namespaces in that payload are available to a Client Component during hydration. The files are on Next.js, the request contract on Server and client APIs and the URL rules on Locale routing. Never compose localeDetector() on the module-scope instance: it runs once, inside the first init(), finds nothing on the server (every source needs a browser) and never looks at a request again. Use nextPlugin(localeDetectorPlugin(…), { runtime: 'client' }).

comvi.i18n.ts
import { attachLoader, createI18n, defineComviI18n } from '@comvi/nuxt/i18n';
export default defineComviI18n((options) => createI18n(options).use(attachLoader));
comvi.setup.ts
import type { LoaderFn, NuxtI18nSetup } from '@comvi/nuxt';
const myLoader: LoaderFn = async (locale, namespace) => {
const res = await fetch(`/i18n/${locale}/${namespace}.json`);
return res.json();
};
export default (({ core }) => {
core.registerLoader(myLoader);
}) satisfies NuxtI18nSetup;

The module builds a fresh instance per request from comvi.i18n.ts, serializes the catalog into the Nuxt payload and hydrates the client from it. Fetching is yours: the module has no loader option, so the factory composes one and the setup hook registers it. For a static import map, put .use(loader(map)) in the factory instead. The factory receives Nuxt’s resolved options and returns a new instance from createI18n on every call; in development the module throws when it gets the same instance twice.

Write no type argument on NuxtI18nSetup. The module writes the factory’s return type to a declaration under .nuxt/types/, so a registerLoader call on a factory that never composed the loader fails nuxi typecheck instead of throwing per request. Destructure core, not i18n: i18n is a union of the Vue wrapper and the core instance. The module writes lang and dir on <html> from the request’s locale (comvi.head, default on); useLocaleHead() adds canonical, hreflang and og:locale. Routing and switching are on Nuxt.

src/routes/+layout.server.ts
import { createI18n } from '@comvi/svelte';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ url }) => {
const locale = url.pathname.split('/')[1] || 'en';
const i18n = createI18n({ locale, fallbackLocale: 'en' }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);
await i18n.init();
const namespace = i18n.getDefaultNamespace();
const messages = {
[`${locale}:${namespace}`]: i18n.getMessages(locale, namespace) ?? {},
};
return { locale, messages };
};
src/routes/+layout.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { createI18n, setI18nContext } from '@comvi/svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const i18n = createI18n({
locale: data.locale,
fallbackLocale: 'en',
messages: data.messages,
});
setI18nContext(i18n, { autoInit: false });
</script>
{@render children()}

Neither binding ships server helpers for its framework. Build a request-scoped instance on the server, return plain data from load (never the instance, which devalue cannot serialize), and create the client context from that payload with autoInit: false. Include every namespace a page needs in messages before rendering it.

src/routes/index.tsx
import { createResource, Show } from 'solid-js';
import { createI18n, I18nProvider, useI18n } from '@comvi/solid';
import { loadI18n } from '~/lib/i18n';
export default function Home() {
const [data] = createResource(() => loadI18n('en'));
return (
<Show when={data()}>
{(ready) => {
const i18n = createI18n({
locale: ready().locale,
fallbackLocale: 'en',
messages: ready().messages,
});
return (
<I18nProvider i18n={i18n} ssrLocale={ready().locale} autoInit={false}>
<Content />
</I18nProvider>
);
}}
</Show>
);
}
function Content() {
const { t } = useI18n();
return <h1>{t('home.title')}</h1>;
}
src/lib/i18n.ts
import { createI18n } from '@comvi/solid';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
export async function loadI18n(locale: string) {
'use server';
const i18n = createI18n({ locale, fallbackLocale: 'en' }).use(
cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }),
);
await i18n.init();
const namespace = i18n.getDefaultNamespace();
return {
locale,
messages: { [`${locale}:${namespace}`]: i18n.getMessages(locale, namespace) ?? {} },
};
}

On Solid, ssrLocale is adopted onto the instance in the provider’s render phase, inside renderToString, so the instance is built inside the route. Server-side loading through the provider is not supported: it initializes from a createEffect, which does not run during the server render, so the catalog is inline. ssrLocale paints on the first render when that locale’s catalogs are already on the instance, inline messages as in the snippet or an earlier load. When they still have to load, the first render is in the instance’s current locale and the switch lands when the load completes. The request locale comes from the route, useParams() or useLocation() from @solidjs/router, in place of the 'en' the snippet hands loadI18n. Wrap loadI18n in query() from @solidjs/router to dedupe it across routes.

navigator.language and localStorage do not exist on the server, so read the URL path, a cookie, the Accept-Language header or a query parameter from the request. The @comvi/plugin-locale-detector sources all skip on the server and the instance keeps its locale option. Next.js and Nuxt read the request for you, in one order: the URL path, then localeDetection.order, by default ['query', 'cookie', 'accept-language'] with query inert until queryParam is set, then defaultLocale. The server helper that hands you the result follows each framework’s idiom, getI18n({ locale }) in Next.js and useTranslation(event) in Nuxt, and both return the same { t, hasTranslation, locale }. Each writer has its own cookie name:

Writer Default cookie Override with
@comvi/next (defineRouting, createMiddleware) NEXT_LOCALE localeCookie: a name, { name, maxAge, secure, sameSite, domain }, or false
@comvi/nuxt i18n_locale localeCookie, the same shape
@comvi/plugin-locale-detector i18n_lang lookupCookie

The Next.js middleware and the Nuxt server utilities share one matcher. Entries are split on commas and tried by descending q; q=0 and negative values are dropped; ties keep the client’s order. Each tag is tried against locales as an exact match, then its base language, then a configured locale under that base. * matches nothing, and the configured spelling comes back: pt-br against ['pt-BR'] resolves to pt-BR. Only the first parameter after the tag is read as q, so en;level=1;q=0.5 reads as q=1.

An instance that goes out of scope is collected, which frees the catalog but never runs a plugin’s cleanup. An interval, a socket or a subscription lives until destroy():

  • Nuxt: the module. Its Nitro plugin destroys every instance a request built once the response is sent. See Server utilities.
  • Vue SSR: your request handler. try { await renderToString(app); } finally { await i18n.destroy(); }.
  • SolidStart: the route owns the instance and SolidStart owns renderToString, so no request handler of yours holds a reference, and the per-request instance is collected without plugin cleanups. Compose nothing that holds a resource on it; the recipe’s two instances compose cdnLoader only, which has nothing to clean up.
  • SvelteKit: the load-built instance in the snippet composes cdnLoader only and is collected. An instance with a plugin that holds a resource belongs in a handle hook in src/hooks.server.ts, built on event.locals, with destroy() awaited after resolve().
  • Next.js: nobody. The instance is process-scoped and outlives every request.

destroy() on @comvi/vue, @comvi/solid and @comvi/svelte resolves once plugin cleanups have finished. A catalog is never evicted until clearMessages() or destroy(), and the compiled-template and Intl caches are bounded by entry count, so an instance with a fixed set of locales and namespaces is a bounded amount of memory. See Limitations.

flowchart LR
R[Request with locale] --> S[Server i18n instance]
S --> L[Loader]
L --> C["Catalog locale:namespace"]
C --> H[HTML]
C --> P[Payload]
P --> K[Client i18n instance]
H --> Y[Hydration]
K --> Y
One request: the server instance loads the catalog, renders HTML, and ships the catalog in the payload; the client instance is built from that payload before hydration.

An i18n instance is one object holding the current locale, the translation cache and the active namespaces, and t() reads the locale from it on every call. Two requests rendering at once on one instance would see each other’s locale, so every framework that writes the locale onto the instance builds one per request: Nuxt from the comvi.i18n.ts factory, SvelteKit in load, Solid by assigning ssrLocale onto the instance inside the route, Vue by seeding its locale ref from ssrLocale. Next.js is the exception because nothing writes the locale on the server. getI18n({ locale }) passes the locale into each t() call as a parameter, and <ComviProvider> keeps it in a React cache() slot that lives for one request for the no-argument helpers, so one process-wide instance serves every request in its own locale and never changes state between them.

The instance never crosses to the client. What travels is the flattened catalog, keyed locale:namespace, the same shape the cache holds. Nuxt writes the request instance’s cache into the page payload after the app has rendered, and the client plugin adds it to a fresh instance with addMessages() before hydration. Next’s <ComviProvider> loads the same shape as messages, including the fallback chain’s catalogs so the client can render the same fallback the server did; the hydrating render reads it through a view, and the provider adds it to the client instance at the first commit. On SvelteKit and SolidStart you build it from getMessages(). Because the client starts from that catalog, autoInit: false or an inline messages keeps hydration from fetching again, and a namespace absent from the payload is missing on the client until something loads it there.

Hydration agrees when both sides render the same locale from the same catalog with the same grammar. The locale is ssrLocale, or the locale prop in Next, which the client instance adopts before its first render instead of starting from the configured default. The catalog is the payload. The grammar is the tag import, which has to be in both bundles or in neither. A detector breaks the first of these when it runs on the server: the locale-detector sources skip there, but a detector composed on a module-scope instance would still run during the shared init(), and on Next that one init() serves the whole process, so the first request’s answer would pin every later render. Compose it on the client only, through nextPlugin(…, { runtime: 'client' }), where it runs after hydration and can switch the locale without disagreeing with the server.

  • Next.js: the App Router files and the two-runtime split.
  • Nuxt: module configuration, routing and useLocaleHead().
  • Locale detector: client-side detection after hydration.