Skip to content

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:

Installation

Install the package with your preferred package manager.

 pnpm add @verbaly/react

Setup & hooks

App.tsx
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>;
}

Rich text with <Trans>

Write the source text in place and the compiler extracts it, exactly like t`…`:

Agree.tsx
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' /> }} />

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.

Next.js needs the integration (@verbaly/next) alongside the React bindings:

 pnpm add verbaly @verbaly/next @verbaly/react

Wire 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:

next.config.ts
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:

app/layout.tsx
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):

app/page.tsx
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.

app/page.tsx
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:

app/og/route.ts
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:

Counter.tsx
'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.

verbaly.config.ts
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.

app/[locale]/layout.tsx
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():

LangPicker.tsx
'use client';
import { useSwitchLocale } from '@verbaly/next/client';

export function LangPicker() {
  const switchLocale = useSwitchLocale();
  return <button onClick={() => switchLocale('es')}>Español</button>;
}
↑↓to moveEnterto open
Copied to clipboard