nop
nop

Reputation: 6311

Next.js 13.x App Router Internationalization with Nested Layouts

I'm working on a Next.js 13 project with the new /app folder routing, and attempting to set up internationalization. My project structure looks like this:

enter image description here

  1. Root AppLayout (src/app/[lang]/layout.tsx)

This component receives a lang props parameter from its route and uses it to fetch a language-specific dictionary (i18nDictionary). The challenge I'm facing is to pass this i18nDictionary to the children of this component because there we can find my actual components.

import React from 'react';
import { getI18nDictionary } from '@/i18n';

type Props = {
  children: React.ReactNode;
  params: {
    lang: string;
  };
};

const AppLayout = async ({ children, params }: Props) => {
  const i18nDictionary = await getI18nDictionary(params.lang);

  return (
    <html lang={params.lang}>
      <body>
        {children}
        {i18nDictionary.home.hero.title}
      </body>
    </html>
  );
};

export default AppLayout;

Have a look at {i18nDictionary.home.hero.title}, that works. I just need to pass the i18nDictionary down to the pipeline.

  1. Child AppLayout (src/app/[lang]/(main)/layout.tsx)

Here are my actual components, which means I have to get this information here one way or another.

import React from 'react';
import type { Metadata } from 'next/types';

import { InitializeChakra } from '@/components/InitializeChakra';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';

export const metadata: Metadata = {
  title: 'Test',
  description: 'Test',
  keywords: 'test',
};

type Props = {
  children: React.ReactNode;
};

const AppLayout = ({ children }: Props) => {
  return (
    <InitializeChakra>
      <Header />
      {children}
      <Footer />
    </InitializeChakra>
  );
};

export default AppLayout;
  1. i18nDictionary (src/i18n/index.tsx)

Here's my internationalization logic:

import { DEFAULT_LOCALE, type SUPPORTED_LOCALES } from '@/middleware';

export type I18nDictionary = {
  [page: string]: {
    [section: string]: {
      [element: string]: string;
    };
  };
};

export type I18nDictionaryGetter = () => Promise<I18nDictionary>;

const i18nDictionaries: {
  [K in (typeof SUPPORTED_LOCALES)[number]]: I18nDictionaryGetter;
} = {
  en: () => import('./en.json').then((module) => module.default),
  bg: () => import('./bg.json').then((module) => module.default),
};

export async function getI18nDictionary(
  locale: string,
): Promise<I18nDictionary> {
  return (i18nDictionaries[locale] || i18nDictionaries[DEFAULT_LOCALE])();
}

Question:

How can I pass the i18nDictionary from the Root AppLayout component to its children props, so that I can use it within the Child AppLayout components, e.g. <Header />?

'use client';

import {
  Box,
  Flex,
  Container,
  Stack,
  useDisclosure,
  IconButton,
  useColorModeValue,
  Icon,
  useColorMode,
  Heading,
} from '@chakra-ui/react';
import { CloseIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
import Link from 'next/link';

import { Logo } from '@/components/Logo';
import { TextUnderline } from '@/components/TextUnderline';
import { MobileNav } from '@/components/Header/MobileNav';
import { DesktopNav } from '@/components/Header/DesktopNav';

export const Header = () => {
  const { isOpen: isMobileNavOpen, onToggle } = useDisclosure();
  const { colorMode, toggleColorMode } = useColorMode();

  return (
    <Box as="header">
      <Flex
        as={'header'}
        pos="fixed"
        top="0"
        w={'full'}
        minH={'60px'}
        boxShadow={'sm'}
        zIndex="999"
        justify={'center'}
        css={{
          backdropFilter: 'saturate(180%) blur(5px)',
          backgroundColor: useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)'),
        }}
      >
        <Container as={Flex} maxW={'7xl'} align={'center'}>
          <Flex
            flex={{ base: '0', md: 'auto' }}
            ml={{ base: -2 }}
            mr={{ base: 6, md: 0 }}
            display={{ base: 'flex', md: 'none' }}
          >
            <IconButton
              onClick={onToggle}
              icon={isMobileNavOpen ? <CloseIcon w={3} h={3} /> : <HamburgerIcon w={5} h={5} />}
              variant={'ghost'}
              size={'sm'}
              aria-label={'Toggle Navigation'}
            />
          </Flex>

          <Flex flex={{ base: 1, md: 'auto' }} justify={{ base: 'start', md: 'start' }}>
            <Stack
              href="/"
              direction="row"
              alignItems="center"
              spacing={{ base: 2, sm: 4 }}
              as={Link}
            >
              <Icon as={Logo} w={{ base: 8 }} h={{ base: 8 }} />
              <Heading as={'h1'} fontSize={'xl'} display={{ base: 'none', md: 'block' }}>
                <TextUnderline>Quant</TextUnderline> Logistics
              </Heading>
            </Stack>
          </Flex>

          <Stack
            direction={'row'}
            align={'center'}
            spacing={{ base: 6, md: 8 }}
            flex={{ base: 1, md: 'auto' }}
            justify={'flex-end'}
          >
            <DesktopNav display={{ base: 'none', md: 'flex' }} />
            <IconButton
              size={'sm'}
              variant={'ghost'}
              aria-label={'Toggle Color Mode'}
              onClick={toggleColorMode}
              icon={colorMode == 'light' ? <SunIcon /> : <MoonIcon />}
            />
          </Stack>
        </Container>
      </Flex>
      <MobileNav isOpen={isMobileNavOpen} />
    </Box>
  );
};

middleware.ts

import { NextRequest, NextResponse } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';

export const DEFAULT_LOCALE = 'en';
export const SUPPORTED_LOCALES = ['en', 'bg'];

export const middleware = (request: NextRequest) => {
  // Check if there is any supported locale in the pathname
  const pathname = request.nextUrl.pathname;
  const pathnameHasLocale = SUPPORTED_LOCALES.some(
    (locale: string) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
  );
  if (pathnameHasLocale) return;

  // Redirect if there is no locale
  const negotiatorHeaders: Negotiator.Headers = {};
  request.headers.forEach((value, key) => {
    negotiatorHeaders[key] = value;
  });
  const languages = new Negotiator({
    headers: negotiatorHeaders,
  }).languages();
  const locale = match(languages, SUPPORTED_LOCALES, DEFAULT_LOCALE);

  // e.g. incoming request is /products
  // The new URL is now /en/products
  return NextResponse.redirect(new URL(`/${locale}/${pathname}`, request.url));
};

export const config = {
  // Skip all internal paths (_next)
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Upvotes: 0

Views: 1100

Answers (1)

Sajjad
Sajjad

Reputation: 478

You may consider using react context api, you can create an I18nContext that provides the i18nDictionary components of application that needs it

This usually known as prop drill avoiding solutions

Example: Provider part:

src/app/[lang]/layout.tsx

import React from 'react';
import { I18nContext } from '@/contexts/I18nContext';

const AppLayout = async ({ children, params }: Props) => {
  const i18nDictionary = await getI18nDictionary(params.lang);

  return (
    <I18nContext.Provider value={i18nDictionary}>
      <html lang={params.lang}>
        <body>
          {children}
        </body>
      </html>
    </I18nContext.Provider>
  );
};

export default AppLayout;

Consumer part:

// src/components/Header.tsx

import { useI18n } from '@/contexts/I18nContext';

export const Header = () => {
  const i18nDictionary = useI18n();
  ...
};

The component:

// src/contexts/I18nContext.tsx

import { createContext, useContext } from 'react';

export const I18nContext = createContext(null);

export const useI18n = () => useContext(I18nContext);

Upvotes: 1

Related Questions