ShipEasy Docs

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:

  1. <ShipEasyI18nInlineData> fetches labels on the server and embeds them as JSON in <head> before any HTML is streamed to the client.
  2. loader.js reads the inline JSON synchronously on parse — before React hydrates.
  3. ShipEasyI18nProvider initializes with ready: true and t() 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.