Render on the server
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.
Where the instance lives
Section titled “Where the instance lives”| 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.
Next.js (App Router)
Section titled “Next.js (App Router)”@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' }).
import { attachLoader, createI18n, defineComviI18n } from '@comvi/nuxt/i18n';
export default defineComviI18n((options) => createI18n(options).use(attachLoader));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.
SvelteKit and SolidStart
Section titled “SvelteKit and SolidStart”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 };};<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.
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>;}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.
Locale on the server
Section titled “Locale on the server”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 |
How the Accept-Language header is matched
Section titled “How the Accept-Language header is matched”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.
Who destroys the per-request instance
Section titled “Who destroys the per-request instance”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 composecdnLoaderonly, which has nothing to clean up. - SvelteKit: the
load-built instance in the snippet composescdnLoaderonly and is collected. An instance with a plugin that holds a resource belongs in ahandlehook insrc/hooks.server.ts, built onevent.locals, withdestroy()awaited afterresolve(). - 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.
How it works
Section titled “How it works”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
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.
Read next
Section titled “Read next”- Next.js: the App Router files and the two-runtime split.
- Nuxt: module configuration, routing and
useLocaleHead(). - Locale detector: client-side detection after hydration.