From 50700478a3d86d68d1242449ac5aaf7bff6c9f11 Mon Sep 17 00:00:00 2001 From: Pablo Sanchez Date: Tue, 7 Jul 2026 17:24:52 +0300 Subject: [PATCH 1/2] Next.js app router support --- packages/react/README.md | 236 +++++++++++++++++++++++ packages/react/package.json | 2 +- packages/react/scripts/add-use-client.js | 28 +++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 packages/react/scripts/add-use-client.js diff --git a/packages/react/README.md b/packages/react/README.md index ffb5725a..ffad9681 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -443,6 +443,242 @@ export default function App() { } ``` +# Next.js + +## App Router + +All components and hooks in this package are Client Components (they rely on +React hooks and context), and the built bundle is published with the +`"use client"` directive. This means you can import `T`, `UT`, `LanguagePicker`, +`TXProvider` and the hooks directly inside the Next.js App Router without getting +the "you're importing a component that needs `useState` ..." error, as long as +they render inside a Client Component subtree. + +Because the App Router renders on the server across concurrent requests, do +**not** rely on the global `tx` singleton for server rendering. Instead create a +per-request instance with `createNativeInstance` so locales from different +requests cannot leak into each other. + +### Rendering translations in a Server Component + +Create a small per-request helper: + +```js +// app/i18n/getServerTx.js +import { createNativeInstance, normalizeLocale } from '@transifex/native'; + +export async function getServerTx(locale) { + const txLocale = normalizeLocale(locale); // e.g. pt-br -> pt_BR + const instance = createNativeInstance({ + token: process.env.NEXT_PUBLIC_TRANSIFEX_TOKEN, + currentLocale: txLocale, + }); + await instance.fetchTranslations(txLocale); + return instance; +} +``` + +Use it inside an async Server Component (for example a `[locale]` segment): + +```jsx +// app/[locale]/page.jsx +import { getServerTx } from '../i18n/getServerTx'; + +export default async function Page({ params }) { + const { locale } = await params; + const tx = await getServerTx(locale); + return

{tx.t('Hello world')}

; +} +``` + +> In Next.js 15+, `params` is a Promise and must be awaited (as shown above). +> In Next.js 14 and earlier, `params` is a plain object, so you can read +> `params.locale` directly without `await`. + +You can enumerate the available locales for `generateStaticParams` with +`tx.getLocales()`. + +### Hydrating Client Components + +For interactive parts that use `T`, hooks or the `LanguagePicker`, fetch the +translations on the server and pass them into a Client Component that seeds a +`TXProvider`: + +```jsx +// app/i18n/TxClientProvider.jsx +'use client'; + +import { useMemo } from 'react'; +import { createNativeInstance } from '@transifex/native'; +import { TXProvider } from '@transifex/react'; + +export default function TxClientProvider({ locale, translations, children }) { + const instance = useMemo(() => { + const tx = createNativeInstance({ currentLocale: locale }); + tx.cache.update(locale, translations); + return tx; + }, [locale, translations]); + + return {children}; +} +``` + +```jsx +// app/[locale]/layout.jsx (Server Component) +import { normalizeLocale } from '@transifex/native'; +import { getServerTx } from '../i18n/getServerTx'; +import TxClientProvider from '../i18n/TxClientProvider'; + +export default async function LocaleLayout({ children, params }) { + const { locale } = await params; + const txLocale = normalizeLocale(locale); + const tx = await getServerTx(locale); + const translations = tx.cache.getTranslations(txLocale); + + return ( + + {children} + + ); +} +``` + +Client Components rendered inside the provider can then use `T` and the hooks as +usual. + +### App Router notes and limitations + +- The App Router does not use the `i18n` option in `next.config.js`. Set up + locale routing with a `[locale]` dynamic segment (or middleware) instead. +- `publicRuntimeConfig` is not available in the App Router. Use environment + variables (`NEXT_PUBLIC_*`) for the public token. +- Live language switching, over-the-air auto-refresh and `LanguagePicker` + require Client Components. +- Server-side rendering of translations (`tx.t(...)` in a Server Component) uses + the per-request instance and does not ship JavaScript for that content. + +## Pages Router + +The Pages Router uses Next.js built-in [Internationalized Routing](https://nextjs.org/docs/pages/building-your-application/routing/internationalization) +(`i18n` in `next.config.js`) together with `getServerSideProps` to fetch +translations on the server and hydrate the client. In this model the global `tx` +singleton is the recommended approach — each `getServerSideProps` call runs in +isolation per request. + +### Configure locales and token + +```js +// next.config.js +module.exports = { + i18n: { + locales: ['en', 'fr', 'de', 'pt-BR'], + defaultLocale: 'en', + localeDetection: false, + }, + publicRuntimeConfig: { + TxNativePublicToken: process.env.NEXT_PUBLIC_TRANSIFEX_TOKEN, + }, +}; +``` + +### Create a Transifex utility + +```js +// lib/i18n.js +import { tx, normalizeLocale } from '@transifex/native'; +import getConfig from 'next/config'; + +const { publicRuntimeConfig } = getConfig(); + +/** + * Used by SSR to pass translations to the browser. + * + * @param {{ locale: string, locales: string[] }} context + * @returns {{ locale: string, locales: string[], translations: object }} + */ +export async function getServerSideTranslations({ locale, locales }) { + tx.init({ + token: publicRuntimeConfig.TxNativePublicToken, + }); + + const txLocale = normalizeLocale(locale); + await tx.fetchTranslations(txLocale); + + return { + locale, + locales, + translations: tx.cache.getTranslations(txLocale), + }; +} + +/** + * Initialize the client-side Transifex Native cache from server props. + * + * @param {{ locale: string, translations: object }} props + */ +export function setClientSideTranslations({ locale, translations }) { + if (!locale || !translations) return; + tx.init({ currentLocale: locale }); + tx.cache.update(locale, translations); +} +``` + +### Load translations per page + +```jsx +// pages/index.js +import { T } from '@transifex/react'; +import { getServerSideTranslations, setClientSideTranslations } from '../lib/i18n'; + +export default function Home(props) { + setClientSideTranslations(props); + + return ( +
+ +
+ ); +} + +export async function getServerSideProps(context) { + const data = await getServerSideTranslations(context); + return { props: { ...data } }; +} +``` + +### Set translations globally in `_app.js` + +To avoid calling `setClientSideTranslations` on every page, initialize the +client cache once in your custom App: + +```jsx +// pages/_app.js +import { setClientSideTranslations } from '../lib/i18n'; + +export default function MyApp({ Component, pageProps }) { + setClientSideTranslations(pageProps); + return ; +} +``` + +You still need `getServerSideProps` on each page that requires translations, +because `_app.js` does not support data fetching methods. + +### Pages Router notes and limitations + +- Locale routing is handled by Next.js `i18n` config — no `[locale]` segment or + middleware is required. +- `publicRuntimeConfig` is available and is the documented way to pass the public + token to server-side code. +- `T`, `UT`, hooks and `LanguagePicker` work directly in page components without + a `"use client"` boundary (the Pages Router does not use React Server + Components). +- For over-the-air translation refresh without a server restart, add a TTL-based + refresh in `getServerSideTranslations` (see the + [Next.js guide](https://developers.transifex.com/docs/nextjs#auto-refresh-translations)). +- For large sites, consider content splitting with tagged fetches to reduce data + transfer. + # License Licensed under Apache License 2.0, see [LICENSE](https://github.com/transifex/transifex-javascript/blob/HEAD/LICENSE) file. diff --git a/packages/react/package.json b/packages/react/package.json index 92bc9fa6..80fc2689 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -23,7 +23,7 @@ }, "repository": "git://github.com/transifex/transifex-javascript.git", "scripts": { - "build": "NODE_ENV=production microbundle-crl --no-compress --format modern,cjs && cp src/index.d.ts dist/index.d.ts && cp src/index.d.ts dist/index.modern.d.ts", + "build": "NODE_ENV=production microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js && cp src/index.d.ts dist/index.d.ts && cp src/index.d.ts dist/index.modern.d.ts", "prepare": "run-s build", "lint": "eslint src/ tests/", "test": "run-s test:unit test:build", diff --git a/packages/react/scripts/add-use-client.js b/packages/react/scripts/add-use-client.js new file mode 100644 index 00000000..beead92a --- /dev/null +++ b/packages/react/scripts/add-use-client.js @@ -0,0 +1,28 @@ +/* Prepend the React Server Components `"use client"` directive to the built + * bundles. + * + * Every export in this package (components and hooks) relies on client-only + * React features (useState, useEffect, useContext, createContext), so the whole + * package is a Client Component boundary. The bundler (microbundle/rollup) + * strips module-level directives from the source, so we re-add the banner to the + * emitted bundles here. Without it, importing this package from a Next.js App + * Router Server Component throws a "needs to be a Client Component" error. */ + +const fs = require('fs'); +const path = require('path'); + +const DIRECTIVE = '\'use client\';'; +const DIST_DIR = path.resolve(__dirname, '..', 'dist'); +const FILES = ['index.js', 'index.modern.js']; + +FILES.forEach((file) => { + const filePath = path.join(DIST_DIR, file); + if (!fs.existsSync(filePath)) return; + + const contents = fs.readFileSync(filePath, 'utf8'); + if (contents.startsWith(DIRECTIVE)) return; + + fs.writeFileSync(filePath, `${DIRECTIVE}\n${contents}`); + // eslint-disable-next-line no-console + console.log(`Prepended "use client" to dist/${file}`); +}); From 8516b8ebca37c6ab978a26a24b837ecb46b0d290 Mon Sep 17 00:00:00 2001 From: Pablo Sanchez Date: Wed, 8 Jul 2026 18:01:06 +0300 Subject: [PATCH 2/2] Add post-build checks --- packages/react/package.json | 1 + packages/react/scripts/add-use-client.js | 2 +- packages/react/scripts/check-use-client.js | 38 +++++++++++++++++++ .../react/scripts/use-client-directive.js | 4 ++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/react/scripts/check-use-client.js create mode 100644 packages/react/scripts/use-client-directive.js diff --git a/packages/react/package.json b/packages/react/package.json index 80fc2689..fe812b1e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -24,6 +24,7 @@ "repository": "git://github.com/transifex/transifex-javascript.git", "scripts": { "build": "NODE_ENV=production microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js && cp src/index.d.ts dist/index.d.ts && cp src/index.d.ts dist/index.modern.d.ts", + "postbuild": "node scripts/check-use-client.js", "prepare": "run-s build", "lint": "eslint src/ tests/", "test": "run-s test:unit test:build", diff --git a/packages/react/scripts/add-use-client.js b/packages/react/scripts/add-use-client.js index beead92a..61687e30 100644 --- a/packages/react/scripts/add-use-client.js +++ b/packages/react/scripts/add-use-client.js @@ -10,8 +10,8 @@ const fs = require('fs'); const path = require('path'); +const DIRECTIVE = require('./use-client-directive'); -const DIRECTIVE = '\'use client\';'; const DIST_DIR = path.resolve(__dirname, '..', 'dist'); const FILES = ['index.js', 'index.modern.js']; diff --git a/packages/react/scripts/check-use-client.js b/packages/react/scripts/check-use-client.js new file mode 100644 index 00000000..a493408c --- /dev/null +++ b/packages/react/scripts/check-use-client.js @@ -0,0 +1,38 @@ +/* Assert that the built bundles start with the `"use client"` directive. + * + * The build relies on scripts/add-use-client.js to prepend the directive to the + * emitted bundles. This guard fails the build/test if the directive is missing + * (e.g. the post-build step was removed, or a bundler upgrade changed the dist + * output), which would otherwise silently break Next.js App Router consumers + * with a "needs to be a Client Component" error. */ + +const fs = require('fs'); +const path = require('path'); +const DIRECTIVE = require('./use-client-directive'); + +const DIST_DIR = path.resolve(__dirname, '..', 'dist'); +const FILES = ['index.js', 'index.modern.js']; + +const errors = []; + +FILES.forEach((file) => { + const filePath = path.join(DIST_DIR, file); + if (!fs.existsSync(filePath)) { + errors.push(`dist/${file} does not exist (was the build run?)`); + return; + } + + const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0].trim(); + if (firstLine !== DIRECTIVE) { + errors.push(`dist/${file} is missing the "use client" directive (found: ${JSON.stringify(firstLine)})`); + } +}); + +if (errors.length) { + // eslint-disable-next-line no-console + console.error(`"use client" check failed:\n- ${errors.join('\n- ')}`); + process.exit(1); +} + +// eslint-disable-next-line no-console +console.log('"use client" directive present in all dist bundles'); diff --git a/packages/react/scripts/use-client-directive.js b/packages/react/scripts/use-client-directive.js new file mode 100644 index 00000000..c2407aa2 --- /dev/null +++ b/packages/react/scripts/use-client-directive.js @@ -0,0 +1,4 @@ +/* Single source of truth for the React Server Components client boundary + * directive, shared by the add/check post-build scripts. */ + +module.exports = '\'use client\';';