Skip to content
fluenti

React Server Components

React Server Components render on the server and never ship JavaScript to the client. Fluenti supports RSC via the t`...` tagged template (when using withFluenti()) or createServerI18n from @fluenti/react/server.

For a general overview of Next.js integration, see the Next.js guide.

When using withFluenti() in your next.config.ts, Server Components can use the same t`...` syntax as Client Components. The plugin detects the component type and applies the correct transform:

// app/page.tsx (Server Component — no 'use client')
export default async function Page() {
return (
<div>
<h1>{t`Welcome to our app`}</h1>
<p>{t`We have been building great things.`}</p>
</div>
)
}

No imports needed — withFluenti() handles the setup automatically.

For advanced patterns (custom locale resolution, server-bound components, streaming), use createServerI18n from @fluenti/react/server:

  1. setLocale(locale) — stores the locale in a request-scoped async context (Node.js AsyncLocalStorage)
  2. getI18n() — reads the locale from the async context and returns a bound i18n instance
  3. Pre-bound components (Trans, Plural, etc.) — automatically resolve their locale from the async context
lib/i18n.server.ts
import { createServerI18n } from '@fluenti/react/server'
export const { setLocale, getI18n, Trans, Plural, DateTime, NumberFormat } = createServerI18n({
loadMessages: (locale) => import(`../locales/compiled/${locale}.js`),
fallbackLocale: 'en',
resolveLocale: async () => {
const { cookies } = await import('next/headers')
return (await cookies()).get('locale')?.value ?? 'en'
},
})

Request Scope — How It Works Under the Hood

Section titled “Request Scope — How It Works Under the Hood”

React Server Components cannot use useContext because they render on the server without a persistent component tree. Fluenti uses React.cache() to provide per-request isolation instead.

In traditional React, providers share state via Context. But RSC renders are stateless — there is no component tree to hold context. Each request needs its own isolated i18n instance with the correct locale and messages.

React.cache() creates a memoized function whose cache is scoped to a single server request. Fluenti uses this to create a request-local store:

// Simplified from @fluenti/react/server
const getRequestStore = cache(() => ({
locale: null, // set by setLocale()
instance: null, // cached i18n instance
}))

When setLocale('ja') is called in a layout, it writes to this request-scoped store. All subsequent getI18n() calls within the same request read from the same store — regardless of which component calls them.

Request 1 (page render) Request 2 (Server Action)
──────────────────────── ─────────────────────────
Layout action handler
└─ setLocale('ja') └─ getI18n()
└─ Page ├─ store.locale == null
└─ getI18n() │ (setLocale was never called)
├─ store.locale = 'ja' ├─ resolveLocale() → 'ja'
└─ returns i18n(ja) └─ returns i18n(ja)

Server Actions are independent HTTP requests — they don’t go through the layout, so setLocale() is never called. The React.cache() store starts empty. Without resolveLocale, getI18n() would throw.

By providing resolveLocale (e.g., reading from a cookie), the action can auto-detect the locale:

const { getI18n } = createServerI18n({
loadMessages: (locale) => import(`../locales/compiled/${locale}.js`),
resolveLocale: async () => {
const { cookies } = await import('next/headers')
return (await cookies()).get('locale')?.value ?? 'en'
},
})

When a component renders inside a <Suspense> boundary during streaming, the React.cache() store may not be populated yet. Fluenti handles this with a module-level fallback (_lastInstance) that caches the most recently created i18n instance. This ensures streamed components can still resolve translations synchronously without throwing.

import { getI18n } from '@/lib/i18n.server'
export default async function Page() {
const { t } = await getI18n()
return <h1>{t('Welcome to our app')}</h1>
}
import { Trans } from '@/lib/i18n.server'
export default async function Page() {
return <Trans>Read the <a href="/docs">documentation</a> to get started.</Trans>
}
import { Plural } from '@/lib/i18n.server'
export default async function Page({ count }) {
return <Plural value={count} one="# result found" other="# results found" />
}
import { DateTime, NumberFormat } from '@/lib/i18n.server'
export default async function Page() {
return (
<div>
<DateTime value={new Date()} style="long" />
<NumberFormat value={1234.5} style="currency" />
</div>
)
}

Server Actions are separate requests that don’t go through the layout’s setLocale() call. The resolveLocale function is called automatically to determine the active locale.

When using withFluenti(), the t`...` macro works directly in 'use server' files — the plugin detects the directive and injects the server-side i18n accessor:

app/actions.ts
'use server'
export async function searchAction(query: string) {
if (!query) return { error: t`Please enter a search term` }
// ...
}

No imports needed — withFluenti() handles the transform automatically.

For dynamic message keys or when using createServerI18n directly:

app/actions.ts
'use server'
import { getI18n } from '@/lib/i18n.server'
export async function searchAction(query: string) {
const { t } = await getI18n() // resolveLocale auto-detects from cookie
if (!query) return { error: t('Please enter a search term') }
// ...
}

By default, withFluenti() reads the locale from a locale cookie. If you store locale preferences in a database, pass a resolveLocale module path:

next.config.ts
import { withFluenti } from '@fluenti/next'
export default withFluenti({
resolveLocale: './lib/resolve-locale',
})({ reactStrictMode: true })

The module must default-export an async function that returns the locale string.

A common pattern is to store each user’s preferred locale in the database and fall back to a cookie for anonymous visitors.

Assuming a users table with a locale column (e.g., Prisma / Drizzle):

prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
locale String? // null = use default
// ...
}

Write the resolver:

lib/resolve-locale.ts
import { cookies } from 'next/headers'
import { auth } from './auth'
import { db } from './db'
export default async function resolveLocale(): Promise<string> {
// 1. Logged-in user → read from DB
const session = await auth()
if (session?.user) {
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { locale: true },
})
if (user?.locale) return user.locale
}
// 2. Anonymous visitor → read from cookie
const cookieStore = await cookies()
return cookieStore.get('locale')?.value ?? 'en'
}

In the layout, the same resolver logic determines the locale for page renders:

app/layout.tsx
import { cookies } from 'next/headers'
import { getDirection } from '@fluenti/core'
// @ts-expect-error — generated at build time
import { FluentProvider } from '@fluenti/next/__generated'
import resolveLocale from '@/lib/resolve-locale'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await resolveLocale()
return (
<html lang={locale} dir={getDirection(locale)}>
<body>
<FluentProvider locale={locale}>{children}</FluentProvider>
</body>
</html>
)
}

Use a Server Action to persist the user’s choice:

app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
export async function setUserLocale(locale: string) {
// Persist for logged-in users
const session = await auth()
if (session?.user) {
await db.user.update({
where: { id: session.user.id },
data: { locale },
})
}
// Always set cookie as fallback (anonymous + SSR pre-auth)
const cookieStore = await cookies()
cookieStore.set('locale', locale, { path: '/', maxAge: 60 * 60 * 24 * 365 })
}

Call it from a Client Component:

components/locale-switcher.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useI18n } from '@fluenti/react'
import { setUserLocale } from '@/app/actions'
export function LocaleSwitcher() {
const { locale, setLocale, preloadLocale } = useI18n()
const router = useRouter()
const switchTo = async (newLocale: string) => {
await setUserLocale(newLocale) // persist to DB + cookie
await setLocale(newLocale) // update client state
router.refresh() // re-render server layout
}
return (
<div>
<button onMouseEnter={() => preloadLocale('en')} onClick={() => switchTo('en')}>
English
</button>
<button onMouseEnter={() => preloadLocale('ja')} onClick={() => switchTo('ja')}>
日本語
</button>
</div>
)
}
ContextHow locale is resolved
Page render (layout)resolveLocale() called directly in layout
Server Component (RSC)Reads from React.cache() store set by layout
Server ActionresolveLocale() called automatically by withFluenti() (layout doesn’t run)
Client ComponentReads from I18nProvider context (set by FluentProvider)

RSC components work with Next.js streaming. Each component independently resolves its locale and messages:

import { Suspense } from 'react'
export default async function Page() {
return (
<div>
<h1>{t`Dashboard`}</h1>
<Suspense fallback={<p>Loading...</p>}>
<AsyncWidget />
</Suspense>
</div>
)
}