Integrations
React
The official React 18/19 package for Verbaly. Hooks over the same reactive core, a compiler-aware <Trans> for rich text, and keys + params fully typed.
What you can accomplish:
- Translate with hooks (useT, useLocale) that re-render exactly when the locale changes
- Write rich text in place: the compiler extracts
<Trans>children like anyt`…` - Keep keys and params fully typed, with autocompletion from your own messages
- Concurrent-safe by construction: subscriptions ride on
useSyncExternalStore - Use it in Preact through
preact/compat, with no extra package
Installation
Install the package with your preferred package manager.
pnpm add @verbaly/reactSetup & hooks
import { VerbalyProvider, useT, useLocale } from '@verbaly/react';
import { verbaly } from 'virtual:verbaly';
<VerbalyProvider instance={verbaly}>
<App />
</VerbalyProvider>
function Inbox() {
const t = useT();
const [locale, setLocale] = useLocale();
return <p>{t('inbox', { count: 3 })}</p>;
}- Re-renders via
useSyncExternalStore: concurrent-safe, SSR-friendly. useVerbaly()exposes the full instance when you need it.- No provider above a component in the browser?
useT()anduseLocale()read the instancevirtual:verbalycreates, so a single-page app or a component test needs no wrapper. On a server the provider is required, since each request has its own instance. useT()hands out a newtwhen the language changes, so auseMemoor an effect keyed on it follows; while nothing changes, it stays the same function.
Rich text with <Trans>
Write the source text in place and the compiler extracts it, exactly like t`…`:
import { Trans } from '@verbaly/react';
// you write:
<Trans>Read the <a href='/terms'>terms</a> before continuing</Trans>
// the compiler extracts "Read the <a>terms</a> before continuing" and rewrites to:
<Trans id='x7Ka9q2f' components={{ a: <a href='/terms' /> }} />- Child attributes are preserved; params (
{count}) becomevalues; nesting works; unknown tags fall back to inner text. - Whitelisted tags render for real:
<em>,<code>and friends in a message become real elements, the same safe whitelist as the DOM interpreter.richTagsoverrides the list; an empty list flattens tags back to plain text. - Readable keys: write
<Trans id="inbox.title">…</Trans>with children and it extracts under that id instead of a hash. - Runtime-first still works: pass
idwithout children (or with your ownvalues/components) and the compiler leaves the element untouched. - JSX whitespace rules apply: a line break between an element and text renders no space; use the
{' '}idiom, extraction stays faithful to what React renders. - Named links: the
linksprop maps tag names to hrefs:<docs>…</docs>renders as a real<a>without a custom component.componentswins overlinkson the same name;javascript:hrefs are blocked. - No provider? Pass the
instanceprop and<Trans>renders straight from it, re-rendering on every locale switch. - Both forms type-check: children in your source,
idat runtime. A<Trans>the compiler never saw, with no plugin or in a file outsideinclude, renders its children as written, like an uncompiledt`…`. - In a React Server Component, import
Transfrom@verbaly/react/server: it renders from theinstanceyou pass, with no provider and no hooks. In Next.js,@verbaly/next/serverexports one that finds the request's instance itself.
Next.js
Server Components render already translated in the visitor's language, and Client Components hydrate with the same locale and catalog: no flash of untranslated text, no hydration mismatch.
- Every page renders in the visitor's language, taken from the address when it carries one, and otherwise from their saved choice or
Accept-Languageheader - No flash of untranslated text: the HTML the server sends and the page after hydration are identical, catalog included
- Each request gets its own instance, so simultaneous visitors never see each other's language
- Turbopack and webpack, both first-class: the
tcompiler runs as a loader on either bundler, on exactly the filesverbaly extractreads
Next.js needs the integration (@verbaly/next) alongside the React bindings:
pnpm add verbaly @verbaly/next @verbaly/reactWire it up
Wrap your config with withVerbaly. It wires live extraction in dev and the message compiler on Turbopack and webpack, and once next build has compiled it lists what verbaly check would fail on, so a command that only loads your config, like next typegen, never meets that report:
import { withVerbaly } from '@verbaly/next';
export default withVerbaly({ /* your Next config */ });
// locales live in your verbaly.config (npx verbaly init), or inline:
export default withVerbaly({}, { locales: ['en', 'es', 'pt'] });In the root layout, getVerbalyProps serializes the negotiated locale and its catalog, and VerbalyProvider seeds the client from them:
import { getRequestLocale, getVerbalyProps } from '@verbaly/next/server';
import { VerbalyProvider } from '@verbaly/next/client';
import { localeDirection } from 'verbaly';
// the language comes from the cookie and Accept-Language, so the shell renders per request
export const instant = false;
export default async function RootLayout({ children }) {
const locale = await getRequestLocale();
const props = await getVerbalyProps();
return (
<html lang={locale} dir={localeDirection(locale)}>
<body>
<VerbalyProvider {...props}>{children}</VerbalyProvider>
</body>
</html>
);
}export const instant = false is for cacheComponents, which create-next-app turns on: a layout that reads the request outside <Suspense> fails the build there, and a language chosen by cookie is exactly that. Without cacheComponents, leave the line out. To keep pages static instead, put the language in the address, below.
Server and Client Components
Server Components get a request-scoped t with await getT(), resolved once per request (the address first when it carries the language, then the cookie, then Accept-Language, then your fallback):
import { getT } from '@verbaly/next/server';
export default async function Page() {
const t = await getT();
return <h1>{t`Welcome back`}</h1>;
}Rich text in a Server Component is the same <Trans> as in React, from @verbaly/next/server: it renders in the request's language with no provider.
import { Trans } from '@verbaly/next/server';
<Trans>Read the <a href="/terms">terms</a> before you go on</Trans>Outside a page, where there is no request to read (an OG image, a sitemap, an email), name the language. That call never reads the request, so the route stays static:
import { getT } from '@verbaly/next/server';
export async function GET() {
const t = await getT({ locale: 'es' });
return Response.json({ title: t('home.lead') });
}With routing: 'no-prefix', a share card can mix two languages: a crawler reads the page in the language it negotiates, while an image that names its language is always in that one. To keep them together, give each language its own url with a prefix routing mode, or let the image read the request like the page does, with getT() and no locale, which renders it on every request.
Client Components use the React bindings, re-exported from @verbaly/next/client:
'use client';
import { useT } from '@verbaly/next/client';
export function Counter() {
const t = useT();
return <p>{t`You have new messages`}</p>;
}Keys are typed: getT() and useT() check every key and its params against your catalog, and getRequestLocale() returns one of your locales. The types live in .verbaly/types.d.ts, next to the generated modules and never committed; TypeScript skips a dot folder, so Verbaly adds that file to the include of your tsconfig.json, the way Next adds .next/types. On a fresh clone, next typegen && tsc --noEmit writes them before it checks them.
The language in the address
If your pages live under app/[locale]/, say so in your config first: routing: 'prefix-all' gives every language its own segment. That line is what gives getAlternates the other languages and sends useSwitchLocale to the new address, and without it setRequestLocale warns once, since the segment and the config would contradict each other.
export default {
locales: ['en', 'es', 'pt'],
routing: 'prefix-all',
};Then hand Verbaly the segment. The address decides the language for everyone who opens the link, and your page can be built ahead of time again: without the segment, Verbaly has to read the request headers to find the language, and reading them is what makes Next rebuild the page on every visit.
import { setRequestLocale, getVerbalyProps, getAlternates } from '@verbaly/next/server';
import { locales } from 'virtual:verbaly';
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }) {
const { locale } = await params;
return { alternates: getAlternates({ path: `/${locale}`, baseUrl: 'https://example.com' }) };
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
setRequestLocale(locale); // before anything calls getT()
return <VerbalyProvider {...(await getVerbalyProps())}>{children}</VerbalyProvider>;
}getAlternates hands generateMetadata the address of this page in every language plus the canonical, which is what tells a search engine that your translations are the same page and not duplicates. It is the same set a statically rendered site gets.
Switching languages
useSwitchLocale follows your routing. When the address carries the language, it moves to the same page in the new language through the app router; when it does not, it switches the client live, writes the cookie the server reads, and re-renders Server Components through router.refresh():
'use client';
import { useSwitchLocale } from '@verbaly/next/client';
export function LangPicker() {
const switchLocale = useSwitchLocale();
return <button onClick={() => switchLocale('es')}>Español</button>;
}- The cookie defaults to
verbaly-locale(same identity as the core's storage key);cookie: falsein the options disables it, for pureAccept-Language. fallbackdefaults to your source locale; negotiation understands q-values and narrows regions (es-PEmatcheses).next buildlists missing, broken and unknown keys once it has compiled, with the exact keys to fill, and finishes, under Yarn PnP too.failOnMissing: truestops it there instead, printed once above Next's own failure line.withVerbalykeeps acompiler.runAfterProductionCompilehook of your own and runs it first. A wrapper applied outsidewithVerbalythat replaces that hook instead of calling it would switch off the build report, so keepwithVerbalyoutermost.- Dev and build show the same words: a text written in your code wins over a hand edit of it in the catalog in both. In dev, the terminal says what would otherwise happen quietly: that replaced edit, a key written with two texts, a translation now older than its source. Each one once, and again if it comes back; a long list stays a few lines, and a catalog saved half-typed pauses live extraction with a one-line reason instead of a stack trace.
next dev,next buildandnext typegensay once, when they start, if a file outside yourincludewritest`…`, which no build translates: in a project withoutsrc/, addcomponents/there.- Reading request headers is what makes a page render on every visit, so pass the
[locale]segment and it stops happening. For a site with no server at all, prefer pre-translated output per locale with verbaly render.