Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

almost impossible to use i18n with dynamic namespaces solution with inertia + react + ssr. #2147

Open
moha-fl-dev opened this issue Dec 20, 2024 Discussed in #2138 · 2 comments

Comments

@moha-fl-dev
Copy link

Discussed in #2138

Originally posted by moha-fl-dev December 16, 2024

// composer.json
  "require": {
        "php": "^8.2",
        "inertiajs/inertia-laravel": "2.0",
        "laravel/framework": "^11.31",
        "laravel/sanctum": "^4.0",
        "laravel/tinker": "^2.9",
        "tightenco/ziggy": "^2.0"
    },
// package.json
 "dependencies": {
        "i18next": "^24.1.0",
        "i18next-http-backend": "^3.0.1",
        "react-i18next": "^15.2.0"
    }
  "devDependencies": {
        "@headlessui/react": "^2.0.0",
        "@inertiajs/react": "^2.0.0",
        "@tailwindcss/forms": "^0.5.3",
        "@types/node": "^18.13.0",
        "@types/react": "^19.0.0",
        "@types/react-dom": "^19.0.0",
        "@vitejs/plugin-react": "^4.2.0",
        "autoprefixer": "^10.4.12",
        "axios": "^1.7.4",
        "concurrently": "^9.0.1",
        "cross-env": "^7.0.3",
        "laravel-vite-plugin": "^1.0",
        "nodemon": "^3.1.9",
        "postcss": "^8.4.31",
        "react": "^19.0.0",
        "react-dom": "^19.0.0",
        "tailwindcss": "^3.2.1",
        "typescript": "^5.0.2",
        "vite": "^6.0"
    },

i use react for the frontend and react-i18next as my translations and localization manager. after 3 days of trying to find a solution that works in ssr i have come up with the following:

// HandleInertiaRequests.php middleware
 public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
            'ziggy' => fn () => [
                ...(new Ziggy)->toArray(),
                'location' => $request->url(),
            ],
            'ns' => ['dashboard', 'profile', 'translation']
        ];
    }
// i18.config
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

export function createI18nInstance(lng = 'en', resources = {}) {
    const instance = i18n.createInstance();
    instance
        .use(initReactI18next)
        .init({
            lng,
            fallbackLng: 'en',
            debug: false,
            resources,
            interpolation: {
                escapeValue: false,
            },
            react: {
                useSuspense: false,
            }
        });

    return instance;
}
//ssr.tsx
import {createInertiaApp, router} from '@inertiajs/react';
import createServer from '@inertiajs/react/server';
import {resolvePageComponent} from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import {RouteName} from 'ziggy-js';
import {route} from '../../vendor/tightenco/ziggy';
import {I18nextProvider, initReactI18next} from "react-i18next";
import Backend from "i18next-http-backend";
import i18n from "i18next";

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer(async (page) => {
        const i18nInstance = i18n.createInstance();

        await i18nInstance
            .use(initReactI18next)
            .use(Backend)
            .init({
                // debug: true,
                backend: {
                //example: https://example.com/locales/{{lng}}/{{ns}}.json
                    loadPath: import.meta.env.VITE_TRANSLATIONS_ENDPOINT,
                },
                lng: 'en',
                ns: page.props.ns,
                fallbackLng: 'en',
                interpolation: {escapeValue: false},
            });

        const initialI18nStore = i18nInstance.store.data;
        const initialLanguage = i18nInstance.language;

        page.props.initialI18nStore = initialI18nStore;
        page.props.initialLanguage = initialLanguage;

        return createInertiaApp({
            page,
            render: ReactDOMServer.renderToString,
            title: (title) => `${title} - ${appName}`,
            resolve: (name) => {
                return resolvePageComponent(
                    `./Pages/${name}.tsx`,
                    import.meta.glob('./Pages/**/*.tsx'),
                )
            },
            setup: ({App, props}) => {

                /* eslint-disable */
                // @ts-expect-error
                global.route<RouteName> = (name, params, absolute) =>
                    route(name, params as any, absolute, {
                        ...page.props.ziggy,
                        location: new URL(page.props.ziggy.location),
                    });
                /* eslint-enable */

                return (
                    <I18nextProvider i18n={i18nInstance}>
                        <App {...props}/>
                    </I18nextProvider>
                );
            },
        })
    },
);
//app.tsx
import '../css/app.css';
import './bootstrap';
import {createInertiaApp} from '@inertiajs/react';

import {resolvePageComponent} from 'laravel-vite-plugin/inertia-helpers';
import {createRoot, hydrateRoot} from 'react-dom/client';
import {I18nextProvider} from "react-i18next";
import {createI18nInstance} from "@/utils/i18n.config";

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => {
        return resolvePageComponent(
            `./Pages/${name}.tsx`,
            import.meta.glob('./Pages/**/*.tsx'),
        )
    },
    setup({el, App, props}) {

        const {initialI18nStore, initialLanguage} = props.initialPage.props;

        const i18nInstance = createI18nInstance(initialLanguage as string, initialI18nStore as {});

        const element = (
            <I18nextProvider i18n={i18nInstance}>
                <App {...props} />
            </I18nextProvider>
        );

        if (import.meta.env.SSR) {
            hydrateRoot(el, element);
        } else {
            createRoot(el).render(element);
        }
    },
    progress: {
        color: '#4B5563',
    },
});

as you can see, i get the translations on the initial render and pass them through to the client. this is to prevent mismatch between client and server because if i dont forward this to the client, react-i18next will fetch on the client after the translations have been fetched on the server.

even though this solution is far from perfect it is the closest i have been able to come to a working solution after 3 days of struggling to what would have been achievable in 20 mins with next-i18-next.

the downside to this solution is that you need to fetch all your translations on the initial render even though you might need only 1 for a given page.

how come app.tsx doesnt run when client side navigating but even more importantly what does run in client side navigation aside from the components re-render that would allow me to have translation and ssr?

@moha-fl-dev moha-fl-dev changed the title almost impossible to use i18n why dynamic namespaces solution with inertia + react + ssr. almost impossible to use i18n with dynamic namespaces solution with inertia + react + ssr. Dec 20, 2024
@AbdenourTadjer33
Copy link

Hi @moha-fl-dev,

I have encountered this problem before, and I solved it like this:

Step 1: Share the User Locale

Start by sharing the user's locale in the handleInertiaRequests middleware. This ensures the locale is consistently available across your app.

// handleInertiaRequests.php
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        'user' => fn() => $request->user(),
        'locale' => fn() => app()->getLocale(),
    ]);
}

Step 2: Register the initializeI18n Function

Create an initializeI18n function to avoid redundant code in both ssr.tsx and app.tsx. This function centralizes the i18n configuration.

// i18n.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";

import ar from "@/lang/ar.json";
import en from "@/lang/en.json";
import fr from "@/lang/fr.json";

export const initializeI18n = (locale: string) => {
    return i18n.use(initReactI18next).init({
        resources: {
            ar: { translation: ar },
            en: { translation: en },
            fr: { translation: fr },
        },
        lng: locale,
        fallbackLng: "en",
        interpolation: {
            escapeValue: false,
        },
    });
};

Step 3: Initialize i18n in ssr.tsx and app.tsx

  • For SSR (ssr.tsx): Initialize i18n with the locale provided from the server-side Inertia response.
// ssr.tsx
import { Ziggy } from "@/ziggy";
import { route } from "ziggy-js";
import { createInertiaApp } from "@inertiajs/react";
import createServer from "@inertiajs/react/server";
import ReactDOMServer from "react-dom/server";
import { initializeI18n } from "./i18n";

createServer((page) => {
    initializeI18n(page.props?.locale);

    global.route = (name, params, absolute, config = Ziggy) =>
        route(name, params, absolute, config);

    return createInertiaApp({
        page,
        render: ReactDOMServer.renderToString,
        resolve: (name) => {
            const pages = import.meta.glob("./pages/**/*.tsx", { eager: true });
            return pages[`./pages/${name}.tsx`];
        },
        setup: ({ App, props }) => <App {...props} />,
    });
});
  • For Client-Side (app.tsx): Use a bootstrap file to initialize i18n when hydration occurs.
// bootstrap.ts
import axios from "axios";
import { initializeI18n } from "./i18n";
import { Ziggy } from "@/ziggy";
import { route } from "ziggy-js";

axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";

globalThis.Ziggy = Ziggy;
globalThis.route = route;

initializeI18n(document.querySelector("html")?.getAttribute('lang')!);

Then, import the bootstrap.ts file in app.tsx to ensure i18n is initialized during hydration:

import "../css/app.css";
import "./bootstrap";

import { createInertiaApp } from "@inertiajs/react";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { hydrateRoot } from "react-dom/client";

const appName = import.meta.env.VITE_APP_NAME || "Laravel";

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob("./pages/**/*.tsx")
        ),
    setup: ({ el, App, props }) => {
        hydrateRoot(el, <App {...props} />);
    },
    progress: {
        color: "#0757ab",
        includeCSS: true,
    },
}).then(() => document.getElementById("app")?.removeAttribute("data-page"));

This structure ensures smooth integration of i18n with Inertia, React, and SSR.

@moha-fl-dev
Copy link
Author

Hi @AbdenourTadjer33

thanks for your help and reply. your solution seems interesting and aside from few differences it is the first solution I tried.

Reason I haven't gone with this solution is the fact that it doesn't support dynamic namespaces and it loads all translations for a given language at once. although fine for a small to medium application, loading all translations at once becomes a problem for moderately larger applications. It is also anti pattern to load resources that aren't needed.

I spent more time researching and trying out different approaches. I think i found a approach that loads only the necessary namespaces when needed and is also scalable.

given I'm a big fan of people sharing solutions to their issues, here is mine

1:

// i18n.config.ts

import i18next, {Resource} from 'i18next';

i18next.init({
    debug: true,
    supportedLngs: ['en', 'nl'],
    fallbackLng: 'en',
    partialBundledLanguages: true,
    ns: [],
    resources: {}
});

export default i18next

/**
 * use addResourceBundle to add the translations to the store
 * this allows us to add namespaces dynamically for every route.
 * it can also set the language
 */
export function addTranslationResource(lng: string, translations: Resource) {

    if (translations) {
        Object.entries(translations).forEach(([namespace, resource]) => {
            if (!i18next.hasResourceBundle(lng, namespace)) {
                i18next.addResourceBundle(lng, namespace, resource, true, true);
            }
        });
    }
    if (i18next.language !== lng) {
        i18next.changeLanguage(lng);
    }
}

NOTE: nothing needs to be added to app.tsx and ssr.tsx

2:

after creating the config for the i18n store, add the languages and namespaces in public/locales{{language}}/{{ns}}.json

Example: public/locales/en/dashboard.json
Example: public/locales/fr/auth.json
etc...

3:

create a helper class that loads namespaces and language on demand. this behavior mimics i18next-http-backend which loads a namespace for a given language

// app/Helpers/TranslationHelper.php

<?php

namespace App\Helpers;

use Illuminate\Support\Facades\File;

class TranslationHelper
{

    public static function getTranslations(string $language, array $namespaces): array
    {
        $translations = [];

        foreach ($namespaces as $namespace) {
            $path = public_path("locales/$language/$namespace.json");
            if (File::exists($path)) {
                $translations[$namespace] = json_decode(File::get($path), true);
            }
        }

        return $translations;
    }
}

4:

create a custom config file for your translations. in this file, a key value pair of route name and namespaces array is kept. benefit of this file is that it allows for managing namespaces in a central place instead of adding a namespace to every route inside a controller

// config/translations.php

<?php

return [
    'routes' => [
        'profile.edit' => ['dashboard', 'profile'], 
        'dashboard' => ['dashboard'],
        'home' => ['dashboard'], 
        'logout' => [], // example: routes for which no ui exists
        'login' => ['auth'], 
    ],
];

5:

modify the HandleInertiaRequests middleware to share the language and the desired namespaces for given route

// HandleInertiaRequests.php

<?php

namespace App\Http\Middleware;

use App\Helpers\TranslationHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;

class HandleInertiaRequests extends Middleware
{
    /**
     * The root template that is loaded on the first page visit.
     *
     * @var string
     */
    protected $rootView = 'app';

    /**
     * Determine the current asset version.
     */
    public function version(Request $request): ?string
    {
        return parent::version($request);
    }

    /**
     * Define the props that are shared by default.
     *
     * @return array<string, mixed>
     */
    public function share(Request $request): array
    {
        // get route name. naming each route is required
        // example: dashboard.index or profile.edit
        $routeName = $request->route()->getName();

        // get the required namespace(s) from the config
        $requiredNamespaces = config('translations.routes')[$routeName];

        // load the resources. json files containing the translations
        // note: is there value is caching this?
        $translations = TranslationHelper::getTranslations(App::getLocale(), $requiredNamespaces);

        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
            'ziggy' => fn () => [
                ...(new Ziggy)->toArray(),
                'location' => $request->url(),
            ],
            'lng' => App::getLocale(),
            'translations' => $translations,
        ];
    }
}

6:

modify index.d.ts in resources/js/types if you are using typescript

// resources/js/types/index.d.ts

import {Config} from 'ziggy-js';
import {Resource} from "i18next";

export interface User {
    id: number;
    name: string;
    email: string;
    email_verified_at?: string;
}

export type PageProps<
    T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
    auth: {
        user: User;
    };
    ziggy: Config & { location: string };
    translations: Resource // add the translation prop. will be available for every route
    lng: string // add the language props. will be available for evey route
};

7:

use the translation in routes and components

// Pages/Auth/Login.tsx
import Checkbox from '@/Components/Checkbox';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import {PageProps} from "@/types";
import {addTranslationResource} from "@/utils/i18n.config";
import i18next from "@/utils/i18n.config";

export default function Login({
    status,
    canResetPassword,
    lng,
    translations
}:PageProps< {
    status?: string;
    canResetPassword: boolean;
}>) {

     // add the translation for this route to the i18n config. this must be done for every route.
    // NOTE: this function must be called in the parent component for the route
    addTranslationResource(lng, translations)

    const { t} = i18next // note: use the exported i18n instance from config file. import i18next from "@/utils/i18n.config";

    const { data, setData, post, processing, errors, reset } = useForm({
        email: '',
        password: '',
        remember: false,
    });

    const submit: FormEventHandler = (e) => {
        e.preventDefault();

        post(route('login'), {
            onFinish: () => reset('password'),
        });
    };

    return (
        <GuestLayout>
            <Head title="Log in" />

            {status && (
                <div className="mb-4 text-sm font-medium text-green-600">
                    {status}
                </div>
            )}

            <form onSubmit={submit}>
                <div>
                    <InputLabel htmlFor="email" value={t('auth:email')} />

                    <TextInput
                        id="email"
                        type="email"
                        name="email"
                        value={data.email}
                        className="mt-1 block w-full"
                        autoComplete="username"
                        isFocused={true}
                        onChange={(e) => setData('email', e.target.value)}
                    />

                    <InputError message={errors.email} className="mt-2" />
                </div>

                <div className="mt-4">
                    <InputLabel htmlFor="password" value={t('auth:password')} />

                    <TextInput
                        id="password"
                        type="password"
                        name="password"
                        value={data.password}
                        className="mt-1 block w-full"
                        autoComplete="current-password"
                        onChange={(e) => setData('password', e.target.value)}
                    />

                    <InputError message={errors.password} className="mt-2" />
                </div>

                <div className="mt-4 block">
                    <label className="flex items-center">
                        <Checkbox
                            name="remember"
                            checked={data.remember}
                            onChange={(e) =>
                                setData('remember', e.target.checked)
                            }
                        />
                        <span className="ms-2 text-sm text-gray-600">
                           {t('auth:remember_me')}
                        </span>
                    </label>
                </div>

                <div className="mt-4 flex items-center justify-end">
                    {canResetPassword && (
                        <Link
                            href={route('password.request')}
                            className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
                        >
                            {t('auth:forgot_password')}
                        </Link>
                    )}

                    <PrimaryButton className="ms-4" disabled={processing}>
                        {t('auth:login')}
                    </PrimaryButton>
                </div>
            </form>
        </GuestLayout>
    );
}

8:

using the translation in underlying/child components:

steps:

  1. make sure the translation is loaded in the parent component
  2. const { t} = i18next // note: use the exported i18n instance from config file. import i18next from "@/utils/i18n.config";
  3. use the translation. beware to use the namespace. example: t('auth:password')

that is it.

Benefits to this approach are as follows:

  1. SSR friendly. because each route in Laravel returns the required namespace(s)
  2. dynamic namespace loading. i.e. load only the necessary namespace(s) and only when and where needed
  3. have Laravel be in charge of returning the resources for the namespace(s). Also beneficial to the SSR process
  4. scalable because your translations(json file) live in the public folder and namespaces for routes is defined in config
  5. performant because the application doesn't have to download or import content that is not needed

Downsides to this approach:

  1. I'm a PHP and Laravel noob.
  2. you cant use const { t, i18n } = useTranslation(); from react-i18next like you would in next.js. i dont know if this is a downside but it is worth mentioning
  3. changing the language requires full page reload

Lastly: if anyone has a better approach, please share

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants