diff --git a/docs/pages/docs/environments/actions-metadata-route-handlers.mdx b/docs/pages/docs/environments/actions-metadata-route-handlers.mdx index 36104a611..355e45031 100644 --- a/docs/pages/docs/environments/actions-metadata-route-handlers.mdx +++ b/docs/pages/docs/environments/actions-metadata-route-handlers.mdx @@ -6,8 +6,8 @@ import {Tabs, Tab} from 'nextra-theme-docs'; There are a few places in Next.js apps where you can apply internationalization outside of React components: -1. [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) -2. [Metadata API](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) +1. [Metadata API](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) +2. [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) 3. [Open Graph images](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image) 4. [Manifest](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest) 5. [Sitemap](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap) @@ -15,6 +15,29 @@ There are a few places in Next.js apps where you can apply internationalization `next-intl/server` provides a set of [awaitable functions](/docs/environments/server-client-components#async-components) that can be used in these cases. +### Metadata API + +To internationalize metadata like the page title, you can use functionality from `next-intl` in the [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function) function that can be exported from pages and layouts. + +```tsx filename="layout.tsx" +import {getTranslations} from 'next-intl/server'; + +export async function generateMetadata({params: {locale}}) { + const t = await getTranslations({locale, namespace: 'Metadata'}); + + return { + title: t('title') + }; +} +``` + + + By passing an explicit `locale` to the awaitable functions from `next-intl`, + you can make the metadata handler eligible for [static + rendering](/docs/getting-started/app-router/with-i18n-routing#static-rendering) + if you're using [i18n routing](/docs/getting-started/app-router). + + ### Server Actions [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) provide a mechanism to execute server-side code that is invoked by the client. In case you're returning user-facing messages, you can use `next-intl` to localize them based on the user's locale. @@ -85,29 +108,6 @@ See the [App Router without i18n routing example](/examples#app-router-without-i -### Metadata API - -To internationalize metadata like the page title, you can use functionality from `next-intl` in the [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function) function that can be exported from pages and layouts. - -```tsx filename="layout.tsx" -import {getTranslations} from 'next-intl/server'; - -export async function generateMetadata({params: {locale}}) { - const t = await getTranslations({locale, namespace: 'Metadata'}); - - return { - title: t('title') - }; -} -``` - - - By passing an explicit `locale` to the awaitable functions from `next-intl`, - you can make the metadata handler eligible for [static - rendering](/docs/getting-started/app-router/with-i18n-routing#static-rendering) - if you're using [i18n routing](/docs/getting-started/app-router). - - ### Open Graph images If you're programmatically generating [Open Graph images](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image), you can apply internationalization by calling functions from `next-intl` in the exported function: diff --git a/docs/pages/docs/environments/error-files.mdx b/docs/pages/docs/environments/error-files.mdx index 9d713a88e..d2bef6eed 100644 --- a/docs/pages/docs/environments/error-files.mdx +++ b/docs/pages/docs/environments/error-files.mdx @@ -47,7 +47,7 @@ After this change, all requests that are matched within the `[locale]` segment w ### Catching non-localized requests -When the user requests a route that is not matched by [the `next-intl` middleware](/docs/routing/middleware), there's no locale associated with the request (depending on your [`matcher` config](/docs/routing/middleware#matcher-config), e.g. `/unknown.txt` might not be matched). +When the user requests a route that is not matched by the `next-intl` [middleware](/docs/routing/middleware), there's no locale associated with the request (depending on your [`matcher` config](/docs/routing/middleware#matcher-config), e.g. `/unknown.txt` might not be matched). You can add a root `not-found` page to handle these cases too. @@ -77,25 +77,21 @@ export default function RootLayout({children}) { } ``` -For the 404 page to render, we need to call the `notFound` function in [`i18n/request.ts`](/docs/usage/configuration#i18n-request) when we detect an incoming `locale` param that isn't a valid locale. +For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't a valid locale. -```tsx filename="i18n/request.ts" +```tsx filename="app/[locale]/layout.tsx" import {notFound} from 'next/navigation'; -import {getRequestConfig} from 'next-intl/server'; -import {routing} from '@/i18n/routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default function LocaleLayout({children, params: {locale}}) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } - return { - // ... - }; -}); + // ... +} ``` -Note that `next-intl` will also call the `notFound` function internally when it tries to resolve a locale for component usage, but can not find one attached to the request (either from the middleware, or manually via [`unstable_setRequestLocale`](https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering)). - ## `error.js` When an `error` file is defined, Next.js creates [an error boundary within your layout](https://nextjs.org/docs/app/building-your-application/routing/error-handling#how-errorjs-works) that wraps pages accordingly to catch runtime errors: diff --git a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 633ea2bea..9261d2028 100644 --- a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -143,18 +143,23 @@ export const config = { ### `src/i18n/request.ts` [#i18n-request] -`next-intl` creates a request-scoped configuration object, which you can use to provide messages and other options based on the user's locale to Server Components. +When using features from `next-intl` in Server Components, the relevant configuration is read from a central module that is located at `i18n/request.ts` by convention. This configuration is scoped to the current request and can be used to provide messages and other options based on the user's locale. ```tsx filename="src/i18n/request.ts" -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that a valid locale is used + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } return { + locale, messages: (await import(`../../messages/${locale}.json`)).default }; }); @@ -183,6 +188,8 @@ The `locale` that was matched by the middleware is available via the `locale` pa ```tsx filename="app/[locale]/layout.tsx" import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; +import {notFound} from 'next/navigation'; +import {routing} from '@/i18n/routing'; export default async function LocaleLayout({ children, @@ -191,6 +198,11 @@ export default async function LocaleLayout({ children: React.ReactNode; params: {locale: string}; }) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } + // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index f59072fba..ec6f4aad6 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -548,25 +548,14 @@ Note that other [limitations as documented by Next.js](https://nextjs.org/docs/a ## Troubleshooting -### "Unable to find `next-intl` locale because the middleware didn't run on this request." [#unable-to-find-locale] +### "Unable to find `next-intl` locale because the middleware didn't run on this request and no `locale` was returned in `getRequestConfig`." [#unable-to-find-locale] -This can happen either because: +If the middleware is not expected to run on this request (e.g. because you're using a setup [without i18n routing](/docs/getting-started/app-router/without-i18n-routing)), you should explicitly return a `locale` from [`getRequestConfig`](/docs/usage/configuration#i18n-request) to recover from this error. -1. You're using a setup _with_ [i18n routing](/docs/getting-started/app-router) but the middleware is not set up. -2. You're using a setup _without_ [i18n routing](/docs/getting-started/app-router) but are reading the `locale` param passed to the function within `getRequestConfig` or you're not returning a `locale`. -3. The middleware is set up in the wrong file (e.g. you're using the `src` folder, but `middleware.ts` was added in the root folder). -4. The middleware matcher didn't match a request, but you're using APIs from `next-intl` in server code (e.g. a Server Component, a Server Action, etc.). -5. You're attempting to implement static rendering via [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) or [`force-static`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic) but have not followed [the static rendering guide](/docs/getting-started/app-router/with-i18n-routing#static-rendering). +If the error occurs for pathnames where the middleware is expected to run, please make sure that: -To recover from this error, please make sure that: +1. The middleware is set up in the correct file (e.g. `src/middleware.ts`). +2. Your middleware [matcher](#matcher-config) correctly matches all routes of your application, including dynamic segments with potentially unexpected characters like dots (e.g. `/users/jane.doe`). +3. In case you require static rendering, make sure to follow the [static rendering guide](/docs/getting-started/app-router/with-i18n-routing#static-rendering) instead of relying on hacks like [`force-static`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic). -1. You're consistently using a setup with or without [i18n routing](/docs/getting-started/app-router) (i.e. with or without the [routing APIs](/docs/routing)). -2. If you're using a setup _with_ i18n routing: - 1. You're using APIs from `next-intl` (including [the navigation APIs](/docs/routing/navigation)) exclusively within the `[locale]` segment. - 2. Your [middleware matcher](#matcher-config) matches all routes of your application, including dynamic segments with potentially unexpected characters like dots (e.g. `/users/jane.doe`). - 3. If you're using [`localePrefix: 'as-needed'`](/docs/routing#locale-prefix-as-needed), the `locale` segment effectively acts like a catch-all for all unknown routes. You should make sure that the `locale` is [validated](/docs/usage/configuration#i18n-request) before it's used by any APIs from `next-intl`. - 4. To implement static rendering, make sure to [provide a static locale](/docs/getting-started/app-router/with-i18n-routing#static-rendering) to `next-intl` instead of using `force-static`. -3. If you're using using a setup _without_ i18n routing: - 1. You don't read the `locale` param in `getRequestConfig` but instead return it. - -Note that `next-intl` will invoke the `notFound()` function to abort the render if the locale can't be found. You should consider adding [a `not-found` page](/docs/environments/error-files#not-foundjs) due to this. +Note that `next-intl` will invoke the `notFound()` function to abort the render if no locale is available after `getRequestConfig` has run. You should consider adding a [`not-found` page](/docs/environments/error-files#not-foundjs) due to this. diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index 8a7ad9fe4..a70908101 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -7,7 +7,7 @@ import Details from 'components/Details'; Configuration properties that you use across your Next.js app can be set globally. -## Client- and Server Components [#client-server-components] +## Server & Client Components [#server-client-components] Depending on if you handle [internationalization in Server- or Client Components](/docs/environments/server-client-components), the configuration from `i18n/request.ts` or `NextIntlClientProvider` will be applied respectively. @@ -20,15 +20,20 @@ Depending on if you handle [internationalization in Server- or Client Components ```tsx filename="i18n/request.ts" -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from '@/i18n/routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment. + let locale = await requestLocale; + + // Ensure that a valid locale is used + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } return { + locale, messages: (await import(`../../messages/${locale}.json`)).default }; }); @@ -75,6 +80,17 @@ const withNextIntl = createNextIntlPlugin( +
+Which values can the `requestLocale` parameter hold? + +While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: + +1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. +1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). +1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. + +
+ ### `NextIntlClientProvider` `NextIntlClientProvider` can be used to provide configuration for **Client Components**. diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index c611d4fdd..5d3c87400 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,4 +1,6 @@ +import {notFound} from 'next/navigation'; import {ReactNode} from 'react'; +import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; @@ -6,6 +8,11 @@ type Props = { }; export default async function LocaleLayout({children, params}: Props) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(params.locale as any)) { + notFound(); + } + return ( diff --git a/examples/example-app-router-migration/src/i18n/request.ts b/examples/example-app-router-migration/src/i18n/request.ts index c65915183..70066e964 100644 --- a/examples/example-app-router-migration/src/i18n/request.ts +++ b/examples/example-app-router-migration/src/i18n/request.ts @@ -1,12 +1,17 @@ -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that the incoming locale is valid + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } return { + locale, messages: (await import(`../../messages/${locale}.json`)).default }; }); diff --git a/examples/example-app-router-mixed-routing/README.md b/examples/example-app-router-mixed-routing/README.md index 8bb21cdee..abcfa4379 100644 --- a/examples/example-app-router-mixed-routing/README.md +++ b/examples/example-app-router-mixed-routing/README.md @@ -7,11 +7,11 @@ An example of how to achieve locale prefixes on public routes while reading the 2. [Setting up `next-intl` without i18n routing](https://next-intl-docs.vercel.app/docs/getting-started/app-router/without-i18n-routing) **Relevant parts in app code:** -1. `src/middleware.ts`: Add a hint if it's a non-public route that we can read in `i18n.ts`. -2. `src/i18n.ts`: Uses the locale from the pathname segment for public routes or returns a locale from the user profile for internal app routes. -3. `src/navigation.public.ts`: Navigation APIs that automatically consider the `[locale]` segment for public routes. For internal app routes, the navigation APIs from Next.js should be used directly (see `PublicNavigation.tsx` vs `AppNavigation.tsx`). +1. `src/middleware.ts`: Only run middleware on public pages that need to be localized. +2. `src/i18n/request.ts`: Use the locale from the pathname segment for public routes or return a locale from the user profile for internal app routes. +3. `src/navigation.public.ts`: These are the navigation APIs that automatically consider the `[locale]` segment for public routes. For internal app routes, the navigation APIs from Next.js should be used directly (see `PublicNavigation.tsx` vs `AppNavigation.tsx`). -**Note:** Static rendering is currently not supported on public routes since we need to read a header. If this is a requirement, you could alternatively consider a monorepo setup and build the public and internal app separately. This could be a good alternative anyway, if you'd like to separate the code for the public and the internal app. +Note that while this approach works fine, you can alternatively also consider a monorepo setup and build the public and internal app separately if you'd like to separate the code for the two apps. ## Deploy your own diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx index e6e3ed836..a5988d5a7 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx @@ -1,7 +1,15 @@ import {useTranslations} from 'next-intl'; +import {unstable_setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; -export default function About() { +type Props = { + params: {locale: string}; +}; + +export default function About({params: {locale}}: Props) { + // Enable static rendering + unstable_setRequestLocale(locale); + const t = useTranslations('About'); return {t('title')}; } diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index 89fe0be41..d56ec0849 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,16 +1,22 @@ import {Metadata} from 'next'; +import {notFound} from 'next/navigation'; import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; +import {getMessages, unstable_setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import PublicNavigation from './PublicNavigation'; import PublicNavigationLocaleSwitcher from './PublicNavigationLocaleSwitcher'; import Document from '@/components/Document'; +import {locales} from '@/config'; type Props = { children: ReactNode; params: {locale: string}; }; +export function generateStaticParams() { + return locales.map((locale) => ({locale})); +} + export const metadata: Metadata = { title: 'next-intl-mixed-routing (public)' }; @@ -19,6 +25,14 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { + // Enable static rendering + unstable_setRequestLocale(locale); + + // Ensure that the incoming locale is valid + if (!locales.includes(locale as any)) { + notFound(); + } + // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx index dbc5424c0..f626fcb49 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx @@ -1,7 +1,15 @@ import {useTranslations} from 'next-intl'; +import {unstable_setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; -export default function Index() { +type Props = { + params: {locale: string}; +}; + +export default function Index({params: {locale}}: Props) { + // Enable static rendering + unstable_setRequestLocale(locale); + const t = useTranslations('Index'); return {t('title')}; } diff --git a/examples/example-app-router-mixed-routing/src/i18n/request.ts b/examples/example-app-router-mixed-routing/src/i18n/request.ts index 5af925362..ed7b043f6 100644 --- a/examples/example-app-router-mixed-routing/src/i18n/request.ts +++ b/examples/example-app-router-mixed-routing/src/i18n/request.ts @@ -1,34 +1,22 @@ -import {headers} from 'next/headers'; -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; -import {locales} from '../config'; +import {defaultLocale, locales} from '../config'; import {getUserLocale} from '../db'; -async function getConfig(locale: string) { - // Validate that the incoming `locale` parameter is valid - if (!locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + let locale = await requestLocale; + + if (!locale) { + // The user is logged in + locale = await getUserLocale(); + } + + // Ensure that the incoming locale is valid + if (!locales.includes(locale as any)) { + locale = defaultLocale; + } return { + locale, messages: (await import(`../../messages/${locale}.json`)).default }; -} - -export default getRequestConfig(async (params) => { - // Read a hint that was set in the middleware - const isAppRoute = headers().get('x-app-route') === 'true'; - - if (isAppRoute) { - const locale = await getUserLocale(); - - return { - // Return a locale to `next-intl` in case we've read - // it from user settings instead of the pathname - locale, - ...(await getConfig(locale)) - }; - } else { - // Be careful to only read from params if the route is public - const locale = params.locale; - return getConfig(locale); - } }); diff --git a/examples/example-app-router-mixed-routing/src/middleware.ts b/examples/example-app-router-mixed-routing/src/middleware.ts index 01134cf75..f413e76bc 100644 --- a/examples/example-app-router-mixed-routing/src/middleware.ts +++ b/examples/example-app-router-mixed-routing/src/middleware.ts @@ -1,23 +1,9 @@ -import {NextRequest, NextResponse} from 'next/server'; import createMiddleware from 'next-intl/middleware'; import {routing} from './i18n/routing.public'; -export default function middleware(request: NextRequest) { - const pathname = request.nextUrl.pathname; - const isAppRoute = pathname === '/app' || pathname.startsWith('/app/'); - - const intlMiddleware = createMiddleware(routing); - - if (isAppRoute) { - // Add a hint that we can read in `i18n.ts` - request.headers.set('x-app-route', 'true'); - return NextResponse.next({request: {headers: request.headers}}); - } else { - return intlMiddleware(request); - } -} +export default createMiddleware(routing); export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*', '/app/:path*'] + // Match only public pathnames + matcher: ['/', '/(de|en)/:path*'] }; diff --git a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx index a909a3773..0acc48b28 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx @@ -1,13 +1,24 @@ -import {NextIntlClientProvider, useMessages} from 'next-intl'; +import {notFound} from 'next/navigation'; +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; +import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; params: {locale: string}; }; -export default function LocaleLayout({children, params: {locale}}: Props) { - const messages = useMessages(); +export default async function LocaleLayout({ + children, + params: {locale} +}: Props) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } + + const messages = await getMessages(); return ( diff --git a/examples/example-app-router-next-auth/src/i18n/request.ts b/examples/example-app-router-next-auth/src/i18n/request.ts index c65915183..70066e964 100644 --- a/examples/example-app-router-next-auth/src/i18n/request.ts +++ b/examples/example-app-router-next-auth/src/i18n/request.ts @@ -1,12 +1,17 @@ -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that the incoming locale is valid + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } return { + locale, messages: (await import(`../../messages/${locale}.json`)).default }; }); diff --git a/examples/example-app-router-playground/src/app/[locale]/layout.tsx b/examples/example-app-router-playground/src/app/[locale]/layout.tsx index 9a70b0512..9a45e8c4e 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import {Metadata} from 'next'; +import {notFound} from 'next/navigation'; import { getFormatter, getNow, @@ -7,6 +8,7 @@ import { } from 'next-intl/server'; import {ReactNode} from 'react'; import Navigation from '../../components/Navigation'; +import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; @@ -33,6 +35,11 @@ export async function generateMetadata({ } export default function LocaleLayout({children, params: {locale}}: Props) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } + return ( diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index f2ffe6e95..79af138bc 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,5 +1,4 @@ import {headers} from 'next/headers'; -import {notFound} from 'next/navigation'; import {Formats} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; @@ -26,9 +25,14 @@ export const formats = { } } satisfies Formats; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that the incoming locale is valid + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } const now = headers().get('x-now'); const timeZone = headers().get('x-time-zone') ?? 'Europe/Vienna'; @@ -37,6 +41,7 @@ export default getRequestConfig(async ({locale}) => { const messages = {...defaultMessages, ...localeMessages}; return { + locale, now: now ? new Date(now) : undefined, timeZone, messages, diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index c62eb3a7b..b4783fa40 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -682,7 +682,10 @@ it('can use `getPahname` to define a canonical link', async ({page}) => { await expect(getCanonicalPathname()).resolves.toBe('/de/neuigkeiten/3'); }); -it('provides a `Link` that works with Radix Primitives', async ({page}) => { +// https://github.com/radix-ui/primitives/issues/3165 +it.skip('provides a `Link` that works with Radix Primitives', async ({ + page +}) => { await page.goto('/'); await page.getByRole('button', {name: 'Toggle dropdown'}).click(); await page.keyboard.press('ArrowDown'); diff --git a/examples/example-app-router/src/app/[locale]/layout.tsx b/examples/example-app-router/src/app/[locale]/layout.tsx index d5f7ab421..beace3ad2 100644 --- a/examples/example-app-router/src/app/[locale]/layout.tsx +++ b/examples/example-app-router/src/app/[locale]/layout.tsx @@ -1,17 +1,9 @@ -import clsx from 'clsx'; -import {Inter} from 'next/font/google'; -import {NextIntlClientProvider} from 'next-intl'; -import { - getMessages, - getTranslations, - unstable_setRequestLocale -} from 'next-intl/server'; +import {notFound} from 'next/navigation'; +import {getTranslations, unstable_setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; -import Navigation from '@/components/Navigation'; +import BaseLayout from '@/components/BaseLayout'; import {routing} from '@/i18n/routing'; -const inter = Inter({subsets: ['latin']}); - type Props = { children: ReactNode; params: {locale: string}; @@ -35,21 +27,13 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { + // Ensure that the incoming `locale` is valid + if (!routing.locales.includes(locale as any)) { + notFound(); + } + // Enable static rendering unstable_setRequestLocale(locale); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - - return ( - - - - - {children} - - - - ); + return {children}; } diff --git a/examples/example-app-router/src/app/[locale]/not-found.tsx b/examples/example-app-router/src/app/[locale]/not-found.tsx index 24991d40a..c1fd4979b 100644 --- a/examples/example-app-router/src/app/[locale]/not-found.tsx +++ b/examples/example-app-router/src/app/[locale]/not-found.tsx @@ -1,15 +1,4 @@ -import {useTranslations} from 'next-intl'; -import PageLayout from '@/components/PageLayout'; - // Note that `app/[locale]/[...rest]/page.tsx` // is necessary for this page to render. -export default function NotFoundPage() { - const t = useTranslations('NotFoundPage'); - - return ( - -

{t('description')}

-
- ); -} +export {default} from '@/components/NotFoundPage'; diff --git a/examples/example-app-router/src/app/not-found.tsx b/examples/example-app-router/src/app/not-found.tsx index ed4705cb1..69e748520 100644 --- a/examples/example-app-router/src/app/not-found.tsx +++ b/examples/example-app-router/src/app/not-found.tsx @@ -1,17 +1,15 @@ -'use client'; +import BaseLayout from '@/components/BaseLayout'; +import NotFoundPage from '@/components/NotFoundPage'; +import {routing} from '@/i18n/routing'; -import Error from 'next/error'; +// This page renders when a route like `/unknown.txt` is requested. +// In this case, the layout at `app/[locale]/layout.tsx` receives +// an invalid value as the `[locale]` param and calls `notFound()`. -// Render the default Next.js 404 page when a route -// is requested that doesn't match the middleware and -// therefore doesn't have a locale associated with it. - -export default function NotFound() { +export default function GlobalNotFound() { return ( - - - - - + + + ); } diff --git a/examples/example-app-router/src/components/BaseLayout.tsx b/examples/example-app-router/src/components/BaseLayout.tsx new file mode 100644 index 000000000..8907d4e9b --- /dev/null +++ b/examples/example-app-router/src/components/BaseLayout.tsx @@ -0,0 +1,30 @@ +import {clsx} from 'clsx'; +import {Inter} from 'next/font/google'; +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; +import {ReactNode} from 'react'; +import Navigation from '@/components/Navigation'; + +const inter = Inter({subsets: ['latin']}); + +type Props = { + children: ReactNode; + locale: string; +}; + +export default async function BaseLayout({children, locale}: Props) { + // Providing all messages to the client + // side is the easiest way to get started + const messages = await getMessages(); + + return ( + + + + + {children} + + + + ); +} diff --git a/examples/example-app-router/src/components/NotFoundPage.tsx b/examples/example-app-router/src/components/NotFoundPage.tsx new file mode 100644 index 000000000..9fdf94f35 --- /dev/null +++ b/examples/example-app-router/src/components/NotFoundPage.tsx @@ -0,0 +1,12 @@ +import {useTranslations} from 'next-intl'; +import PageLayout from './PageLayout'; + +export default function NotFoundPage() { + const t = useTranslations('NotFoundPage'); + + return ( + +

{t('description')}

+
+ ); +} diff --git a/examples/example-app-router/src/i18n/request.ts b/examples/example-app-router/src/i18n/request.ts index b04a04503..df242f13d 100644 --- a/examples/example-app-router/src/i18n/request.ts +++ b/examples/example-app-router/src/i18n/request.ts @@ -1,12 +1,17 @@ -import {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; -export default getRequestConfig(async ({locale}) => { - // Validate that the incoming `locale` parameter is valid - if (!routing.locales.includes(locale as any)) notFound(); +export default getRequestConfig(async ({requestLocale}) => { + // This typically corresponds to the `[locale]` segment + let locale = await requestLocale; + + // Ensure that the incoming `locale` is valid + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } return { + locale, messages: ( await (locale === 'en' ? // When using Turbopack, this will enable HMR for `en` diff --git a/examples/example-app-router/tests/main.spec.ts b/examples/example-app-router/tests/main.spec.ts index b597def9b..0456be01b 100644 --- a/examples/example-app-router/tests/main.spec.ts +++ b/examples/example-app-router/tests/main.spec.ts @@ -28,9 +28,10 @@ it("handles not found pages for routes that don't match the middleware", async ( page }) => { await page.goto('/test.png'); - page.getByRole('heading', {name: 'This page could not be found.'}); + page.getByRole('heading', {name: 'Page not found'}); + await page.goto('/api/hello'); - page.getByRole('heading', {name: 'This page could not be found.'}); + page.getByRole('heading', {name: 'Page not found'}); }); it('sets caching headers', async ({request}) => { diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 3e357fb49..b338be391 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -9,7 +9,7 @@ const config: SizeLimitConfig = [ { name: 'import * from \'next-intl\' (react-server)', path: 'dist/production/index.react-server.js', - limit: '14.665 KB' + limit: '14.725 KB' }, { name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', @@ -33,19 +33,19 @@ const config: SizeLimitConfig = [ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createSharedPathnamesNavigation}', - limit: '16.515 KB' + limit: '16.635 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createLocalizedPathnamesNavigation}', - limit: '16.545 KB' + limit: '16.635 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createNavigation}', - limit: '16.495 KB' + limit: '16.645 KB' }, { name: 'import * from \'next-intl/server\' (react-client)', @@ -55,7 +55,7 @@ const config: SizeLimitConfig = [ { name: 'import * from \'next-intl/server\' (react-server)', path: 'dist/production/server.react-server.js', - limit: '13.865 KB' + limit: '13.995 KB' }, { name: 'import createMiddleware from \'next-intl/middleware\'', diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx index d1e307eb8..848e5d1c6 100644 --- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import {renderToString} from 'react-dom/server'; import {it, describe, vi, expect, beforeEach} from 'vitest'; import {defineRouting, Pathnames} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocale'; +import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; import {getLocalePrefix} from '../shared/utils'; import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation'; import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation'; @@ -48,7 +48,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({ ); } })); -vi.mock('../../src/server/react-server/RequestLocale', () => ({ +vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ getRequestLocale: vi.fn(() => 'en') })); diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index c7d2978d3..e244ea673 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -31,7 +31,6 @@ function mockCurrentLocale(locale: string) { (localePromise as any).status = 'fulfilled'; (localePromise as any).value = locale; - // @ts-expect-error -- Async values are allowed vi.mocked(getRequestLocale).mockImplementation(() => localePromise); vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx index b54612181..29868c92a 100644 --- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import {renderToString} from 'react-dom/server'; import {it, describe, vi, expect, beforeEach} from 'vitest'; import {defineRouting} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocale'; +import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; import {getLocalePrefix} from '../shared/utils'; import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation'; import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation'; @@ -48,7 +48,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({ ); } })); -vi.mock('../../src/server/react-server/RequestLocale', () => ({ +vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ getRequestLocale: vi.fn(() => 'en') })); diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 808f45484..01e05e493 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -9,7 +9,7 @@ import { Locales, Pathnames } from '../../routing/types'; -import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; import {ParametersExceptFirst} from '../../shared/types'; import { HrefOrHrefWithParams, diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 8e5f5c0bc..13055861a 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -35,7 +35,7 @@ export default function createNavigation< type Locale = AppLocales extends never ? string : AppLocales[number]; function getLocale() { - return getRequestLocale() as Locale; + return getRequestLocale() as Promise; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/next-intl/src/navigation/react-server/redirects.tsx b/packages/next-intl/src/navigation/react-server/redirects.tsx index 435e5dfa9..8b848a9cf 100644 --- a/packages/next-intl/src/navigation/react-server/redirects.tsx +++ b/packages/next-intl/src/navigation/react-server/redirects.tsx @@ -1,4 +1,4 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; import {ParametersExceptFirst} from '../../shared/types'; import {baseRedirect, basePermanentRedirect} from '../shared/redirects'; diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index a97ad1c2a..de0a04679 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -14,8 +14,8 @@ describe('getRequestConfig', () => { const getConfig = getRequestConfig(({locale}) => ({ messages: {hello: 'Hello ' + locale} })); - expect(() => getConfig({locale: 'en'})).toThrow( - '`getRequestConfig` is not supported in Client Components.' - ); + expect(() => + getConfig({locale: 'en', requestLocale: Promise.resolve('en')}) + ).toThrow('`getRequestConfig` is not supported in Client Components.'); }); }); diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 970bb4de5..e92e93397 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,51 +1,43 @@ import {headers} from 'next/headers'; -import {notFound} from 'next/navigation'; import {cache} from 'react'; import {HEADER_LOCALE_NAME} from '../../shared/constants'; +import {getCachedRequestLocale} from './RequestLocaleCache'; -function getLocaleFromHeaderImpl() { +async function getHeadersImpl(): Promise { + const promiseOrValue = headers(); + + // Compatibility with Next.js <15 + return promiseOrValue instanceof Promise + ? await promiseOrValue + : promiseOrValue; +} +const getHeaders = cache(getHeadersImpl); + +async function getLocaleFromHeaderImpl(): Promise { let locale; try { - locale = headers().get(HEADER_LOCALE_NAME); + locale = (await getHeaders()).get(HEADER_LOCALE_NAME) || undefined; } catch (error) { if ( error instanceof Error && (error as any).digest === 'DYNAMIC_SERVER_USAGE' ) { - throw new Error( + const wrappedError = new Error( 'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `unstable_setRequestLocale` API to enable static rendering, see https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering', {cause: error} ); + (wrappedError as any).digest = (error as any).digest; + throw wrappedError; } else { throw error; } } - if (!locale) { - if (process.env.NODE_ENV !== 'production') { - console.error( - `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` - ); - } - notFound(); - } - return locale; } const getLocaleFromHeader = cache(getLocaleFromHeaderImpl); -// Workaround until `createServerContext` is available -function getCacheImpl() { - const value: {locale?: string} = {locale: undefined}; - return value; -} -const getCache = cache(getCacheImpl); - -export function setRequestLocale(locale: string) { - getCache().locale = locale; -} - -export function getRequestLocale(): string { - return getCache().locale || getLocaleFromHeader(); +export async function getRequestLocale() { + return getCachedRequestLocale() || (await getLocaleFromHeader()); } diff --git a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx new file mode 100644 index 000000000..a8bc80194 --- /dev/null +++ b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx @@ -0,0 +1,17 @@ +import {cache} from 'react'; + +// See https://github.com/vercel/next.js/discussions/58862 +function getCacheImpl() { + const value: {locale?: string} = {locale: undefined}; + return value; +} + +const getCache = cache(getCacheImpl); + +export function getCachedRequestLocale() { + return getCache().locale; +} + +export function setCachedRequestLocale(locale: string) { + getCache().locale = locale; +} diff --git a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx new file mode 100644 index 000000000..31727d6d9 --- /dev/null +++ b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx @@ -0,0 +1,48 @@ +import {headers} from 'next/headers'; +import {notFound} from 'next/navigation'; +import {cache} from 'react'; +import {HEADER_LOCALE_NAME} from '../../shared/constants'; +import {getCachedRequestLocale} from './RequestLocaleCache'; + +// This was originally built for Next.js <14, where `headers()` was not async. +// With https://github.com/vercel/next.js/pull/68812, the API became async. +// This file can be removed once we remove the legacy navigation APIs. +function getHeaders() { + return headers(); +} + +function getLocaleFromHeaderImpl() { + let locale; + + try { + locale = getHeaders().get(HEADER_LOCALE_NAME); + } catch (error) { + if ( + error instanceof Error && + (error as any).digest === 'DYNAMIC_SERVER_USAGE' + ) { + throw new Error( + 'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `unstable_setRequestLocale` API to enable static rendering, see https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering', + {cause: error} + ); + } else { + throw error; + } + } + + if (!locale) { + if (process.env.NODE_ENV !== 'production') { + console.error( + `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` + ); + } + notFound(); + } + + return locale; +} +const getLocaleFromHeader = cache(getLocaleFromHeaderImpl); + +export function getRequestLocale(): string { + return getCachedRequestLocale() || getLocaleFromHeader(); +} diff --git a/packages/next-intl/src/server/react-server/createRequestConfig.tsx b/packages/next-intl/src/server/react-server/createRequestConfig.tsx index 29013cbbd..56abb735a 100644 --- a/packages/next-intl/src/server/react-server/createRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/createRequestConfig.tsx @@ -1,8 +1,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import getRuntimeConfig from 'next-intl/config'; -import type {IntlConfig} from 'use-intl/core'; -import type {GetRequestConfigParams} from './getRequestConfig'; +import type {GetRequestConfigParams, RequestConfig} from './getRequestConfig'; export default getRuntimeConfig as unknown as ( params: GetRequestConfigParams -) => IntlConfig | Promise; +) => RequestConfig | Promise; diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 32c0268fb..0a789b0ba 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,3 +1,4 @@ +import {notFound} from 'next/navigation'; import {cache} from 'react'; import { initializeConfig, @@ -6,7 +7,9 @@ import { _createCache } from 'use-intl/core'; import {getRequestLocale} from './RequestLocale'; +import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy'; import createRequestConfig from './createRequestConfig'; +import {GetRequestConfigParams} from './getRequestConfig'; // Make sure `now` is consistent across the request in case none was configured function getDefaultNowImpl() { @@ -41,15 +44,18 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques ); } - let hasReadLocale = false; - - // In case the consumer doesn't read `params.locale` and instead provides the - // `locale` (either in a single-language workflow or because the locale is - // read from the user settings), don't attempt to read the request locale. - const params = { + const params: GetRequestConfigParams = { + // In case the consumer doesn't read `params.locale` and instead provides the + // `locale` (either in a single-language workflow or because the locale is + // read from the user settings), don't attempt to read the request locale. get locale() { - hasReadLocale = true; - return localeOverride || getRequestLocale(); + return localeOverride || getRequestLocaleLegacy(); + }, + + get requestLocale() { + return localeOverride + ? Promise.resolve(localeOverride) + : getRequestLocale(); } }; @@ -58,25 +64,20 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques result = await result; } - if (process.env.NODE_ENV !== 'production') { - if (hasReadLocale) { - if (result.locale) { - console.error( - "\nYou've read the `locale` param that was passed to `getRequestConfig` but have also returned one from the function. This is likely an error, please ensure that you're consistently using a setup with or without i18n routing: https://next-intl-docs.vercel.app/docs/getting-started/app-router\n" - ); - } - } else { - if (!result.locale) { - console.error( - "\nYou haven't read the `locale` param that was passed to `getRequestConfig` and also haven't returned one from the function. This is likely an error, please ensure that you're consistently using a setup with or without i18n routing: https://next-intl-docs.vercel.app/docs/getting-started/app-router\n" - ); - } + const locale = result.locale || (await params.requestLocale); + + if (!locale) { + if (process.env.NODE_ENV !== 'production') { + console.error( + `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request and no \`locale\` was returned in \`getRequestConfig\`. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` + ); } + notFound(); } return { ...result, - locale: result.locale || params.locale, + locale, now: result.now || getDefaultNow(), timeZone: result.timeZone || getDefaultTimeZone() }; diff --git a/packages/next-intl/src/server/react-server/getRequestConfig.tsx b/packages/next-intl/src/server/react-server/getRequestConfig.tsx index d00645709..e735d4be6 100644 --- a/packages/next-intl/src/server/react-server/getRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/getRequestConfig.tsx @@ -1,23 +1,51 @@ import type {IntlConfig} from 'use-intl/core'; -type RequestConfig = Omit & { +export type RequestConfig = Omit & { /** - * Instead of reading a `locale` from the argument that's passed to the + * Instead of reading a `requestLocale` from the argument that's passed to the * function within `getRequestConfig`, you can include a locale as part of the * returned request configuration. * - * This is helpful for apps that only support a single language and for apps - * where the locale should be read from user settings instead of the pathname. + * This can be helpful for the following use cases: + * - Apps that only support a single language + * - Apps where the locale should be read from user settings instead of the pathname + * - Providing a fallback locale in case the locale was not matched by the middleware **/ locale?: IntlConfig['locale']; }; export type GetRequestConfigParams = { + /** + * Deprecated in favor of `requestLocale` (see https://github.com/amannn/next-intl/pull/1383). + * + * The locale that was matched by the `[locale]` path segment. Note however + * that this can be overridden in async APIs when the `locale` is explicitly + * passed (e.g. `getTranslations({locale: 'en'})`). + * + * @deprecated + */ locale: string; + + /** + * Typically corresponds to the `[locale]` segment that was matched by the middleware. + * + * However, there are three special cases to consider: + * 1. **Overrides**: When an explicit `locale` is passed to awaitable functions + * like `getTranslations({locale: 'en'})`, then this value will be used + * instead of the segment. + * 2. **`undefined`**: The value can be `undefined` when a page outside of the + * `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). + * 3. **Invalid values**: Since the `[locale]` segment effectively acts like a + * catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should + * be replaced with a valid locale. + * + * @see https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-request + */ + requestLocale: Promise; }; /** - * Should be called in `i18n.ts` to create the configuration for the current request. + * Should be called in `i18n/request.ts` to create the configuration for the current request. */ export default function getRequestConfig( createRequestConfig: ( diff --git a/packages/next-intl/src/server/react-server/index.tsx b/packages/next-intl/src/server/react-server/index.tsx index c3d85a3ea..0ce84ce95 100644 --- a/packages/next-intl/src/server/react-server/index.tsx +++ b/packages/next-intl/src/server/react-server/index.tsx @@ -10,4 +10,4 @@ export {default as getTranslations} from './getTranslations'; export {default as getMessages} from './getMessages'; export {default as getLocale} from './getLocale'; -export {setRequestLocale as unstable_setRequestLocale} from './RequestLocale'; +export {setCachedRequestLocale as unstable_setRequestLocale} from './RequestLocaleCache';