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.
Why SSR Matters for i18n
Section titled “Why SSR Matters for i18n”Without SSR, translations load on the client after JavaScript executes. This causes three problems:
- SEO — Search engines index the page before translations arrive, so they see raw keys or fallback text instead of localized content.
- First paint — Users see a loading skeleton or key names for a moment before translations appear.
- 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.
General Approach
Section titled “General Approach”Regardless of framework, the SSR pattern is the same:
- Load translations on the server before rendering begins.
- Render the page with the fully populated i18n instance.
- Serialize the translations into the HTML response so the client can hydrate without a duplicate network request.
- Hydrate on the client using the serialized translations.
Each framework handles these steps differently. The sections below cover the specifics.
Next.js (App Router)
Section titled “Next.js (App Router)”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.
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
Section titled “Server Components”Server Components use getI18n() from @comvi/next/server. It returns a translation function and helpers:
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
Section titled “Client Components”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:
'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.
Locale Layout
Section titled “Locale Layout”The locale layout initializes translations for the detected locale before any page renders:
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> );}'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.
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-Languageheader (configurable) - Loads translations server-side before rendering
- Serializes translations into the Nuxt payload for client hydration
- Sets the
langanddirattributes 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:
import { defineComviSetup } from '@comvi/nuxt/runtime/setup';
export default defineComviSetup(({ i18n }) => { i18n.registerLoader({ en: () => import('./locales/en.json'), de: () => import('./locales/de.json'), });});<script setup lang="ts">const { t } = useI18n();</script>
<template> <h1>{{ t('home.title') }}</h1></template>SvelteKit
Section titled “SvelteKit”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.
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 };};<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 /><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.
SolidStart
Section titled “SolidStart”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.
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');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).
Hydration Mismatch Prevention
Section titled “Hydration Mismatch Prevention”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
How to Prevent Mismatches
Section titled “How to Prevent Mismatches”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 detectionconst locale = getLocaleFromURL(request.url) // URL path ?? getLocaleFromCookie(request.headers) // Cookie ?? 'en'; // Default2. 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.
Caching Translations Server-Side
Section titled “Caching Translations Server-Side”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:
.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:
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:
import { i18n } from './i18n';
// Load all supported languages at server startconst supportedLocales = ['en', 'de', 'fr'];
for (const lang of supportedLocales) { await i18n.reloadTranslations(lang);}
// Translations are now cached in memory for all requestsLanguage Detection on the Server
Section titled “Language Detection on the Server”On the server, you cannot access navigator.language or localStorage. Use these sources instead:
| Source | How to Access | Best For |
|---|---|---|
| URL path | Parse /de/about from the request URL | Apps with locale-prefixed routes |
| Cookie | Read the comvi_locale cookie from the request headers | Returning visitors |
Accept-Language header | Parse the Accept-Language HTTP header | First-time visitors |
| Query parameter | Read ?lang=de from the URL | Shared 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().