# Render on the server

Render translated HTML on the server in Next.js, Nuxt, SvelteKit and SolidStart: where the i18n instance lives, how the locale reaches a request, and who destroys the instance.

Canonical: https://comvi.io/docs/i18n/ssr

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](/docs/i18n/quick-start) and one of `@comvi/next`, `@comvi/nuxt`, `@comvi/svelte`,
`@comvi/solid` or `@comvi/vue`.

## 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.

> **Tags render in both bundles or in neither**
>
> `import "@comvi/core/tags"` registers the tag grammar per bundle, and an SSR app has two.
> Import it on the client only and a `<b>bold</b>` string renders literally on the server and
> with markup after hydration. See [Insert values and markup](/docs/i18n/tag-interpolation).

## 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](/docs/i18n/next), the request contract on
[Server and client APIs](/docs/i18n/guides/next-server-client) and the URL rules on
[Locale routing](/docs/i18n/guides/next-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' })`.

## Nuxt

`comvi.i18n.ts`

```ts

export default defineComviI18n((options) => createI18n(options).use(attachLoader));
```

`comvi.setup.ts`

```ts

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](/docs/i18n/nuxt).

## SvelteKit and SolidStart

`src/routes/+layout.server.ts`

```ts

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`

```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`

```tsx

export default function Home() {
const [data] = createResource(() => loadI18n('en'));

return (

      {(ready) => {
        const i18n = createI18n({
          locale: ready().locale,
          fallbackLocale: 'en',
          messages: ready().messages,
        });
        return (

        );
      }}

);
}

function Content() {
const { t } = useI18n();
return <h1>{t('home.title')}</h1>;
}
```

`src/lib/i18n.ts`

```ts

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

`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

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

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](/docs/i18n/api/nuxt#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](/docs/i18n/limitations#memory).

## How it works

<Mermaid
code={`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`}
caption="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.

## Read next

- [Next.js](/docs/i18n/next): the App Router files and the two-runtime split.
- [Nuxt](/docs/i18n/nuxt): module configuration, routing and `useLocaleHead()`.
- [Locale detector](/docs/i18n/plugins/locale-detector): client-side detection after hydration.
