Skip to content

Server-Side Rendering

Server-side rendering (SSR) ensures that translated content is present in the initial HTML response. This matters for SEO, social sharing previews, and eliminating the flash of untranslated content on first paint.

Without SSR, translations load on the client after JavaScript executes. This causes three problems:

  1. SEO — Search engines index the page before translations arrive, so they see raw keys or fallback text instead of localized content.
  2. First paint — Users see a loading skeleton or key names for a moment before translations appear.
  3. Social sharing — Open Graph crawlers (Slack, Twitter, Facebook) render the server response. Without SSR, previews show untranslated content.

SSR solves all three by rendering the fully translated page on the server.

Regardless of framework, the SSR pattern is the same:

  1. Load translations on the server before rendering begins.
  2. Render the page with the fully populated i18n instance.
  3. Serialize the translations into the HTML response so the client can hydrate without a duplicate network request.
  4. Hydrate on the client using the serialized translations.

Each framework handles these steps differently. The sections below cover the specifics.

The @comvi/next package provides App Router helpers for SSR. Translations are loaded server-side in the locale layout, and the client hydrates from the same data when you pass the returned messages to the client provider.

src/i18n.ts
import { createNextI18n } from '@comvi/next';
import { setI18n } from '@comvi/next/server';
import { FetchLoader } from '@comvi/plugin-fetch-loader';
const nextI18n = createNextI18n({
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
fallbackLocale: 'en',
})
.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
}));
setI18n(nextI18n.i18n);
export const { i18n, routing } = nextI18n;

Server Components use getI18n() from @comvi/next/server. It returns a translation function and helpers:

src/app/[locale]/page.tsx
import { getI18n, setRequestLocale } from '@comvi/next/server';
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const { t, hasTranslation } = await getI18n();
return (
<main>
<h1>{t('home.title')}</h1>
{hasTranslation('home.description') && (
<p>{t('home.description')}</p>
)}
</main>
);
}

Client Components use the standard import from @comvi/next/client. Wrap them in the Next I18nProvider and pass the messages returned by loadTranslations() so they hydrate from the server-rendered translations without an extra fetch:

src/components/Counter.tsx
'use client';
import { useI18n } from '@comvi/next/client';
export function Counter() {
const { t } = useI18n();
return <button>{t('counter.increment')}</button>;
}

The server and client bundles each have their own i18n instance. loadTranslations() returns a serializable messages object, and the client provider adds those messages to the client cache before children render.

The locale layout initializes translations for the detected locale before any page renders:

src/app/[locale]/layout.tsx
import { setRequestLocale, loadTranslations } from '@comvi/next/server';
import { I18nClientProvider } from './I18nClientProvider';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// 1. Store locale in async context
setRequestLocale(locale);
// 2. Fetch translations server-side (before rendering)
const messages = await loadTranslations(locale);
return (
<html lang={locale}>
<body>
<I18nClientProvider locale={locale} messages={messages}>
{children}
</I18nClientProvider>
</body>
</html>
);
}
src/app/[locale]/I18nClientProvider.tsx
'use client';
import { I18nProvider, type MessagesMap } from '@comvi/next/client';
import { i18n, routing } from '@/i18n';
export function I18nClientProvider({
locale,
messages,
children,
}: {
locale: string;
messages: MessagesMap;
children: React.ReactNode;
}) {
return (
<I18nProvider i18n={i18n} locale={locale} messages={messages} routing={routing}>
{children}
</I18nProvider>
);
}

loadTranslations(locale) defaults to fetching only the default namespace. To preload more, pass them explicitly:

await loadTranslations(locale, { namespaces: ['common', 'dashboard', 'auth'] });

Any child components (server or client) that use translations from a preloaded namespace get instant access. Server Components can still auto-load the default namespace through getI18n(), but only namespaces included in the messages payload are available to Client Components during hydration.

The @comvi/nuxt module handles SSR out of the box. No manual SSR configuration is needed.

nuxt.config.ts
export default defineNuxtConfig({
modules: ['@comvi/nuxt'],
comvi: {
defaultLocale: 'en',
locales: [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
],
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
},
});

The module automatically:

  • Detects the locale from the URL path, cookie, or Accept-Language header (configurable)
  • Loads translations server-side before rendering
  • Serializes translations into the Nuxt payload for client hydration
  • Sets the lang and dir attributes on <html>
  • Provides useI18n() with locale-aware routing helpers

In your setup file, register the loader. The comvi.setup.ts hook runs on both server and client, so use the defineComviSetup helper for proper typing — the i18n argument is VueI18n on the client and the core I18n on the server:

comvi.setup.ts
import { defineComviSetup } from '@comvi/nuxt/runtime/setup';
export default defineComviSetup(({ i18n }) => {
i18n.registerLoader({
en: () => import('./locales/en.json'),
de: () => import('./locales/de.json'),
});
});
pages/index.vue
<script setup lang="ts">
const { t } = useI18n();
</script>
<template>
<h1>{{ t('home.title') }}</h1>
</template>

Comvi does not ship a dedicated SvelteKit adapter. For SSR, create a request-scoped i18n instance on the server, return the loaded translations from load, and create the client context from that payload. Avoid mutating a shared module-level i18n singleton during server requests because concurrent requests can use different locales.

src/routes/+layout.server.ts
import { createI18n } from '@comvi/svelte';
import { FetchLoader } 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(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
}));
await i18n.init();
const namespace = i18n.getDefaultNamespace();
const messages = {
[`${locale}:${namespace}`]: i18n.getTranslations(locale, namespace) ?? {},
};
return { locale, messages };
};
src/routes/+layout.svelte
<script lang="ts">
import { createI18n, setI18nContext } from '@comvi/svelte';
export let data;
const i18n = createI18n({
locale: data.locale,
fallbackLocale: 'en',
translation: data.messages,
});
setI18nContext(i18n, { autoInit: false });
</script>
<slot />
src/routes/+page.svelte
<script>
import { useI18n } from '@comvi/svelte';
const { t } = useI18n();
</script>
<h1>{$t('home.title')}</h1>

If you lazy-load additional namespaces, include them in the returned messages object before rendering any component that needs them.

Comvi does not ship a dedicated SolidStart adapter. The same rule applies: load translations in a server function, pass them to the route, and render through I18nProvider with autoInit={false} so hydration starts from the same cache.

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

For route-level locale detection, derive the locale from route params or request headers and pass it into loadI18n(locale).

A hydration mismatch occurs when the server renders one thing and the client renders another. For translations, this happens when:

  • The server and client use different languages
  • Translations are not serialized from server to client
  • Dynamic values (dates, relative times) produce different strings on both sides

1. Use the same language on both sides.

Detect the language from the URL or cookie (not from navigator.language, which is only available on the client):

// Server-safe language detection
const locale = getLocaleFromURL(request.url) // URL path
?? getLocaleFromCookie(request.headers) // Cookie
?? 'en'; // Default

2. Pass locale to the client.

In Next.js, pass the locale and preloaded translations from the server layout to the client provider. @comvi/next/client exposes its own I18nProvider that takes a plain locale prop, plus optional messages and routing for hydration:

// app/[locale]/layout.tsx (server)
import { setRequestLocale, loadTranslations } from '@comvi/next/server';
import { I18nClientProvider } from './I18nClientProvider';
export default async function Layout({ params, children }) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await loadTranslations(locale, {
namespaces: ['common', 'dashboard'],
});
return (
<html lang={locale}>
<body>
<I18nClientProvider locale={locale} messages={messages}>
{children}
</I18nClientProvider>
</body>
</html>
);
}
// app/[locale]/I18nClientProvider.tsx (client)
'use client';
import { I18nProvider, type MessagesMap } from '@comvi/next/client';
import { i18n, routing } from '@/i18n';
export function I18nClientProvider({
locale,
messages,
children,
}: {
locale: string;
messages: MessagesMap;
children: React.ReactNode;
}) {
return (
<I18nProvider i18n={i18n} locale={locale} messages={messages} routing={routing}>
{children}
</I18nProvider>
);
}

(The plain @comvi/react provider takes ssrInitialLocale instead — the Next.js wrapper renames the prop to locale and forwards it for you.)

3. Avoid navigator-based detection during SSR.

The Locale Detector plugin skips navigator on the server automatically. If you write custom logic, guard it:

const detectedLocale = typeof window !== 'undefined'
? navigator.language.split('-')[0]
: 'en';

4. Use consistent date/number formatting.

If your translations contain dynamic dates, ensure the server and client use the same locale and timezone.

When running SSR at scale, the Fetch Loader respects standard HTTP cache headers from the CDN. The cache option lets you control Next.js fetch caching:

src/i18n.ts
.use(FetchLoader({
cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
cache: {
revalidate: 3600, // Revalidate every hour (ISR)
tags: ['comvi-translations'], // For on-demand revalidation
},
}))

For Next.js, use the tags option to trigger revalidation when translations change:

app/api/revalidate-i18n/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
// Called by a webhook when translations are published
revalidateTag('comvi-translations');
return Response.json({ revalidated: true });
}

For long-running Node servers with one process, you can preload a small translation set at startup:

server.ts
import { i18n } from './i18n';
// Load all supported languages at server start
const supportedLocales = ['en', 'de', 'fr'];
for (const lang of supportedLocales) {
await i18n.reloadTranslations(lang);
}
// Translations are now cached in memory for all requests

On the server, you cannot access navigator.language or localStorage. Use these sources instead:

SourceHow to AccessBest For
URL pathParse /de/about from the request URLApps with locale-prefixed routes
CookieRead the comvi_locale cookie from the request headersReturning visitors
Accept-Language headerParse the Accept-Language HTTP headerFirst-time visitors
Query parameterRead ?lang=de from the URLShared links with explicit language

The @comvi/next middleware and @comvi/nuxt module handle this detection automatically. The Language Detector plugin relies on browser APIs (window, document, navigator) so all its sources are skipped during SSR — the i18n instance keeps the locale default from createI18n().