Next.js
ShipEasyI18n integration for Next.js App Router (React Server Components + streaming SSR) and Pages Router. Zero hydration mismatches.
Package: @i18n/next
The key challenge with Next.js is that App Router Server Components run on the server with no window.i18n, and streaming HTML must contain label data before the client JS hydrates — otherwise React sees a mismatch between server-rendered text and client-rendered text.
@i18n/next solves this with <ShipEasyI18nInlineData> — a Server Component that fetches labels at request time and embeds them as JSON in <head>, making them available synchronously before any hydration runs.
Install
npm install @i18n/next @i18n/react
Environment variables
# .env.local
ShipEasyI18n_KEY=i18n_pk_your_key_here
ShipEasyI18n_PROFILE=en:prod
# Optional: secret token for CI publish steps — never expose to client
ShipEasyI18n_SECRET_TOKEN=i18n_at_your_secret_token
App Router
Root layout
// app/layout.tsx
import Script from 'next/script';
import { ShipEasyI18nInlineData, ShipEasyI18nScriptTag } from '@i18n/next';
import { ShipEasyI18nProvider } from '@i18n/next/client';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
{/* 1. Inline label data — fetched on server, embedded before hydration */}
<ShipEasyI18nInlineData
i18nKey={process.env.ShipEasyI18n_KEY!}
profile="en:prod"
chunk="index"
/>
{/* 2. Loader script reads inline data immediately on parse */}
<ShipEasyI18nScriptTag
i18nKey={process.env.ShipEasyI18n_KEY!}
profile="en:prod"
/>
</head>
<body>
{/* 3. Client provider gives React components access to t() */}
<ShipEasyI18nProvider ssrLocale="en:prod">
{children}
</ShipEasyI18nProvider>
</body>
</html>
);
}
Server Component: await t()
In React Server Components you have no browser context. Use the server-side t() which fetches labels at request time (cached for 60 seconds via next: { revalidate: 60 }):
// app/dashboard/page.tsx — Server Component
import { t } from '@i18n/next/server';
export default async function DashboardPage() {
const greeting = await t('user.greeting', { name: 'Alice' });
return (
<main>
<h1
data-label="user.greeting"
data-variables={JSON.stringify({ name: 'Alice' })}
>
{greeting}
</h1>
</main>
);
}
The data-label attribute is required so the in-browser editor can identify and overlay the string.
Client Component: useShipEasyI18n()
// components/NavBar.tsx
'use client';
import { useShipEasyI18n, ShipEasyI18nString } from '@i18n/next/client';
export function NavBar() {
const { t } = useShipEasyI18n();
return (
<nav>
{/* Declarative — no hook needed */}
<ShipEasyI18nString labelKey="nav.home" />
{/* Imperative */}
<a href="/patients">{t('nav.patients')}</a>
</nav>
);
}
Per-request locale (Accept-Language)
For apps where different users get different languages:
// app/layout.tsx
import { headers } from 'next/headers';
import { ShipEasyI18nInlineData, ShipEasyI18nScriptTag } from '@i18n/next';
import { ShipEasyI18nProvider } from '@i18n/next/client';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const hdrs = await headers();
const lang = hdrs.get('accept-language')?.split(',')[0] ?? 'en';
const profile = lang.startsWith('fr') ? 'fr:prod' : 'en:prod';
return (
<html lang={lang}>
<head>
<ShipEasyI18nInlineData i18nKey={process.env.ShipEasyI18n_KEY!} profile={profile} chunk="index" />
<ShipEasyI18nScriptTag i18nKey={process.env.ShipEasyI18n_KEY!} profile={profile} />
</head>
<body>
<ShipEasyI18nProvider ssrLocale={profile}>{children}</ShipEasyI18nProvider>
</body>
</html>
);
}
Pages Router
_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
import { getShipEasyI18nInlineDataScript } from '@i18n/next/server';
export default function Document() {
const inlineScript = getShipEasyI18nInlineDataScript({
i18nKey: process.env.ShipEasyI18n_KEY!,
profile: 'en:prod',
chunk: 'index',
});
return (
<Html>
<Head>
<script
id="i18n-data"
type="application/json"
dangerouslySetInnerHTML={{ __html: inlineScript }}
/>
<script
src="https://cdn.i18n.shipeasy.ai/v1/loader.js"
data-key={process.env.ShipEasyI18n_KEY}
data-profile="en:prod"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
_app.tsx
import type { AppProps } from 'next/app';
import { ShipEasyI18nProvider } from '@i18n/next/client';
export default function App({ Component, pageProps }: AppProps) {
return (
<ShipEasyI18nProvider ssrLocale={pageProps.i18nLocale ?? 'en:prod'}>
<Component {...pageProps} />
</ShipEasyI18nProvider>
);
}
getServerSideProps
import { fetchLabels } from '@i18n/next/server';
export async function getServerSideProps() {
const labels = await fetchLabels({
i18nKey: process.env.ShipEasyI18n_KEY!,
profile: 'en:prod',
chunk: 'index',
});
return {
props: { i18nLocale: 'en:prod', initialLabels: labels },
};
}
How hydration mismatches are prevented
Without @i18n/next, the server renders the built-in string (e.g. "Sign in") and the client re-renders with the override (e.g. "Log in"). React detects the difference and throws a hydration warning, then re-renders the entire subtree.
@i18n/next fixes this in three steps:
<ShipEasyI18nInlineData>fetches labels on the server and embeds them as JSON in<head>before any HTML is streamed to the client.loader.jsreads the inline JSON synchronously on parse — before React hydrates.ShipEasyI18nProviderinitializes withready: trueandt()returns the override on the very first render.
Both server and client render the same string → no mismatch.
Multiple chunks per page
<head>
<ShipEasyI18nInlineData i18nKey={process.env.ShipEasyI18n_KEY!} profile="en:prod" chunk="index" />
<ShipEasyI18nInlineData i18nKey={process.env.ShipEasyI18n_KEY!} profile="en:prod" chunk="checkout" />
</head>
Or prefetch lazily after mount:
// Prefetch checkout chunk when user hovers the cart
window.i18n.prefetch('checkout');
Static export (next export)
Static export has no server-side fetch per request. Accept the CDN fetch on first load (~50ms), which is safe since there's no server HTML to mismatch against. Or generate inline data at build time:
// next.config.mjs — generate static label data at build time
import { fetchLabels } from '@i18n/next/server';
const labels = await fetchLabels({ i18nKey: '...', profile: 'en:prod', chunk: 'index' });
// embed as environment variable or static file
Package exports
@i18n/next
/server — fetchLabels(), t(), getShipEasyI18nInlineDataScript(), ShipEasyI18nInlineData (Server Component)
/client — 'use client' re-exports from @i18n/react: ShipEasyI18nProvider, useShipEasyI18n, ShipEasyI18nString
ShipEasyI18nScriptTag — next/script wrapper (strategy="beforeInteractive")
Never import from @i18n/next/server in a Client Component — it imports next/headers which is server-only. Use @i18n/next/client in Client Components.