amannn / next-intl

🌐 Internationalization (i18n) for Next.js
https://next-intl-docs.vercel.app
MIT License
2.4k stars 217 forks source link

Integration with multi-tenancy #1107

Open amannn opened 4 months ago

amannn commented 4 months ago

Is your feature request related to a problem? Please describe.

next-intl currently requires a top-level [locale] segment. While we support custom prefixes like /uk β†’ /en-gb with https://github.com/amannn/next-intl/pull/1086, users have expressed the desire to add additional segments like app/[tenant]/[locale].

Related: https://github.com/amannn/next-intl/issues/1055

(this issue was extracted from https://github.com/amannn/next-intl/issues/653)

Describe the solution you'd like

The solution here really depends on if:

  1. The [tenant] segment comes before the [locale] segment
  2. You support a dynamic number of tenants
  3. The [tenant] segment is visible to the user in the public pathname
  4. Your "tenant" is a domain and there is a finite number of domains

Ad 1) If the [tenant] segment comes after the [locale] segment, this should already work. The only assumption from next-intl is that the [locale] segment comes right at the beginning.

Ad 2) In case you support a finite number of tenants that are known at build time, you could use custom prefixes to define them as necessary (e.g. 'en-US-x-tenant1': '/tenant1/en-US'). Alternatively, you could also deploy your app separately for each tenant and set a basePath for each tenant.

Ad 3) If the [tenant] segment is not visible to the user, you should already be able to implement this via composing the middleware or if you anyway have a requirement for static rendering, you could consider using unstable_setRequestLocale to pass a locale that depends on the tenant.

Ad 4) This use case is already supported pretty well via the domains config option.

Therefore many use cases should already be possible. The one that remains is where the routing looks like [tenant]/[locale], you support a dynamic number of tenants and the tenant is visible to the user in the pathname (e.g. /tenant1/en).

Continuing on the work from https://github.com/amannn/next-intl/pull/1086, maybe we could support a configurable pattern that is incorporated by the middleware and the navigation APIs.

Example API:

import {LocalePrefix} from 'next-intl/routing';

export const locales = ['en-US', 'en-GB'] as const;

export const localePrefix = {
  mode: 'always',
  pattern: '/[tenant]/[locale]'
} satisfies LocalePrefix<typeof locales>;

Since we have two or more dynamic segments following each other, next-intl requires knowledge about which segment to retrieve the locale from.

It would be great to research how common this pattern is, therefore please leave a thumbs up here, leave a comment with your use case and possibly subscribe to updates if this is relevant for you!

Describe alternatives you've considered

If you're out of other options, you could also consider providing your own implementation for the middleware and navigation APIs (see e.g. https://github.com/amannn/next-intl/issues/609). Note that you can still use component APIs like useTranslations in this case.

Gawdfrey commented 4 months ago

Our use case is usually having the opposite '/[locale]/[market]'. Which works-ish so far. Routing is usually a pain afterwards, because we do not have the same utility functions as we next-intl provides, where you can just define the path without having to worry about the dynamic parameters. I've been thinking about this for a while and I think it would be very nice if next-intl was able to support N-number of dynamic params.

This is of course a bit more complicated as you write πŸ˜…

amannn commented 4 months ago

Which works-ish so far.

Do you have some details about the parts that don't work? Which localePrefix setting do you use? Since the [locale] prefix comes first, I'm wondering if the built-in routing capabilities from next-intl could work for you here.

That being said, do you support an infinite number of markets? Otherwise, with the newly added capability for customization of prefixes, maybe you could define them statically.

E.g.:

import {LocalePrefix} from 'next-intl/routing';

export const locales = ['en-US', 'en-UK', /* ... */] as const;

export const localePrefix = {
  mode: 'always',
  prefixes: {
    'en-US': '/en/us',
    'en-UK': '/en/uk'
    // ...
  }
} satisfies LocalePrefix<typeof locales>;

See also: Can I read the matched prefix in my app?

yaman3bd commented 4 months ago

I really love the multi-tenancy support in mind for this library, but I am wondering if the tenant could change the default locale, and the supported locales also managed by the tenant how it would be configured? like in middlewere fetch tenant details then pass the options? also if the messages are customized as well how it would be implemented? in my app right now I am still on the pages router and now migrating to the app router and I faced too many challenges with the setup I am using next-i18next in getServerSideProp first I fetch the tenant based on the request host header then pass the active locales and the default one to the library options idk how it would be possible to handle this use case in next-intl

amannn commented 4 months ago

@yaman3bd Let's continue the conversation from https://github.com/amannn/next-intl/discussions/532#discussioncomment-9677402 here since it's more related to multi-tenancy than switching locales.

What you've shared in the other thread:

App structure:

.
└── app/
    β”œβ”€β”€ [domain]/
    β”‚   └── [locale]/
    β”‚       β”œβ”€β”€ layout.tsx
    β”‚       β”œβ”€β”€ not-found.tsx
    β”‚       └── page.tsx
    β”œβ”€β”€ _components
    β”œβ”€β”€ providers.tsx
    β”œβ”€β”€ robots.ts
    └── sitemap.ts

Notes:

My question would be:

Can you provide some examples how the URLs look like in the browser address bar for the user? Do you wish to implement any rewrites that would hide the [domain] or [locale] segment? Can you by chance share a URL to your app so I can check?

yaman3bd commented 4 months ago

the urls: the tenant can customize their domain to have their own domains smth like: https://courseintelli.com/ or have a subdomain from our root domain: it is: *.msaaq.net so: https://aglowingbrain.msaaq.net/ and yes I will implement rewrite to hide the [domain], for the [locale] segment I will implement rewrite only if the user locale was the default one, I do not want to show it in the URL, otherwise I will have a URL prefix for example, if the tenant default locale is: AR and the user language is also AR I will apply a rewrite to hide it from the URL so the URL is: tenant.msaaq.net but the local param is: AR and if the user detected language is: EN I will implement a redirect this time and keep the locale prefix in the URL

but right now my production app is still on Page Router and I am not implementing any rewrites or redirects for the URLs I just get the host header in getServerSideProps and fetch everything based on it. but now I am migrating to App Router and I thought it would be much better to have it as a param

amannn commented 4 months ago

Thanks, that helps! What is your motivation for the [domain] segment? You could continue to use the host header in Server Components if that has worked well for you so far to read the tenant.

Here's an example that should work if you only have a top-level [locale] segment:

// middleware.tsx

import createIntlMiddleware from 'next-intl/middleware';
import {NextRequest} from 'next/server';

export default async function middleware(request: NextRequest) {
  const tenant = await getTenant(request);

  const handleI18nRouting = createIntlMiddleware({
    locales: tenant.locales,
    localePrefix: 'as-necessary',
    defaultLocale: tenant.defaultLocale
  });

  const response = handleI18nRouting(request);
  return response;
}

// ...
app/
  [locale]
    layout.tsx
    page.tsx

That being said, [domain] could be used to implement static rendering. Might be a bit more tricky, but based on the middleware implementation from above, you could probably modify the response to adapt the rewrite to route to your [domain] segment after the locale negotiation has run:

  const rewrite = response.headers.get('x-middleware-rewrite');
  if (rewrite) {
    response.headers.set('x-middleware-rewrite', /* adapted URL with [domain] segment */)
  }

Let me know if that helps!

yaman3bd commented 4 months ago

const tenant = await getTenant(request);

this is really cool!, I did not know I could fetch data in the middleware! but if the tenant fetch returns 404 or 500 can I return notFound or show the error page? and the tenant messages are also fetched from the CMS should I make the fetch in getRequestConfig?

import { headers } from "next/headers";
import { notFound } from "next/navigation";

import { getRequestConfig } from "next-intl/server";

export default getRequestConfig(async ({ locale }) => {
  const host = headers.get("host");
  const tenant = await getTenant(host);

  const locales = tenant.supported_locales;

  if (!locales.includes(locale as any)) notFound();

  const messages = await getMessages(host, locale);

  return {
    messages: messages
  };
});

but I am wondering if the user navigates to another page is getRequestConfig invoked again? or does it get called on the very first page reload only?

What is your motivation for the [domain] segment?

I think it helps for better isolation for the tenant so the data does not get mixup, because I had an issue that if 2 tenants or more are in the app at the same time and they both accidentally made a page reload the data of the very first tenant who did the reload gets leaked to another tenant the issue was because I was reading the tenant host from the request headers and then assigning it to same axios instance so I think if I have implemented top-level [domain] segment and used to read the tenant from I would not have this issue

and when I want to do a client-side fetch or mutation I need to send the tenant domain with the request headers and it helps for better structure since each tenant can customize the theme, page layout, and page blocks.

amannn commented 4 months ago

but if the tenant fetch returns 404 or 500 can I return notFound or show the error page?

A middleware can return a 404 status code, but unfortunately not a rendered 404 page. So if you need that, you'd have to call notFound in a page/i18n.ts.

the tenant messages are also fetched from the CMS should I make the fetch in getRequestConfig?

Yep!

I had an issue that if 2 tenants or more are in the app at the same time and they both accidentally made a page reload the data of the very first tenant who did the reload gets leaked to another tenant the issue was because I was reading the tenant host from the request headers and then assigning it to same axios instance

I see! Depending on your setup that could still happen, so the better choice is to not assign anything to global singleton instances. If you use fetch in pages and i18n.ts that should be fine.

So if static rendering is not a concern, you might be able to achieve a slighter easier setup by avoiding the [domain] segment.

Hope this helps!

pepijn-vanvlaanderen commented 4 months ago

Our use-case has multiple TLDs (different regions for a storefront) with different kinds of locales. Currently we read the host and based on that determine the tenant/region, but this makes almost all pages dynamic. We could of course create duplicate instances with an ENV for the region, however we already have 8 storefronts and in the future even more.

Having a [domain] part that is rewritten/hidden in this library to also enable static rendering would be really great!

amannn commented 4 months ago

@pepijn-vanvlaanderen Have you by chance tried adapting x-middleware-rewrite as mentioned above in https://github.com/amannn/next-intl/issues/1107#issuecomment-2152373012? Haven't tried it yet, but I think it could work for this use case.

Gawdfrey commented 3 months ago

Which works-ish so far.

Do you have some details about the parts that don't work? Which localePrefix setting do you use? Since the [locale] prefix comes first, I'm wondering if the built-in routing capabilities from next-intl could work for you here.

That being said, do you support an infinite number of markets? Otherwise, with the newly added capability for customization of prefixes, maybe you could define them statically.

E.g.:

import {LocalePrefix} from 'next-intl/routing';

export const locales = ['en-US', 'en-UK', /* ... */] as const;

export const localePrefix = {
  mode: 'always',
  prefixes: {
    'en-US': '/en/us',
    'en-UK': '/en/uk'
    // ...
  }
} satisfies LocalePrefix<typeof locales>;

See also: Can I read the matched prefix in my app?

Sorry for the late reply. Currently using always. The built-in routing capabilities of next-intl do help, but would love if we could extend them to include more dynamic parameters as well πŸ˜…

E.g useRouter allows you to send locale as in the option parameter, but would be cool if we could extend that in user land to include other parameters. Makes it easier to enforce across apps. This also applies to the middleware.

Now I am making some poor extension in the middleware to automatically redirect to the correct market, as well as the routing is a bit cumbersome as we have to construct the url manually.

Again, everything is possible to solve in user-land, but it will most likely be worse than what this library offers for locales.

Our market parameter contains information on both the country, but also the customer segment (B2B/B2C). Might not be the best approach but that is what we have now.

My approach to multi-tenancy might be a bit different to the use-cases of others as well πŸ˜…

pepijn-vanvlaanderen commented 3 months ago

@pepijn-vanvlaanderen Have you by chance tried adapting x-middleware-rewrite as mentioned above in #1107 (comment)? Haven't tried it yet, but I think it could work for this use case.

@amannn I was indeed able to make this work, thanks!

Also for anyone trying the same solution, I also disabled alternateLinks and created the hreflang metas myself in the generateMetadata for every locale per domain.

yaman3bd commented 1 month ago

I see! Depending on your setup that could still happen, so the better choice is to not assign anything to global singleton instances. If you use fetch in pages and i18n.ts that should be fine.

@amannn it worked as a charm, thanks!

I have completed the setup and it is working fine, but I had an issue with createSharedPathnamesNavigation I have to pass the supported locales but my locales are fetched from CMS how would I pass them to the function? in i18n.ts it is working fine

import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
import { fetchTenant } from "@/app/fetch/services/tenant-service";
import { fetchTranslations } from "@/app/fetch/services/translations-service";

export default getRequestConfig(async ({ locale }) => {
  const tenant = await fetchTenant();

  if (!tenant) notFound();

  const locales = tenant.supported_locales.map((locale) => locale.code);
  const defaultLocale = tenant.locale;

  if (!locales.includes(locale)) notFound();
  const translations = (await fetchTranslations());

  return {
    messages: translations[locale] ?? translations[defaultLocale]
  };
});

I could not find a way to export the locales from i18n.ts to createSharedPathnamesNavigation I would appreciate any help or guidance.

amannn commented 1 month ago

it worked as a charm, thanks!

Awesome! πŸ™Œ

I have completed the setup and it is working fine, but I had an issue with createSharedPathnamesNavigation I have to pass the supported locales but my locales are fetched from CMS how would I pass them to the function?

For this very use case, you can not pass locales to createSharedPathnamesNavigationβ€”the setting is optional for this function :). I think I need to mention that in the docs …

yaman3bd commented 1 month ago

I think I need to mention that in the docs

Thank you for your response! I found out that this is actually mentioned in the docs: locales-unknown. sorry for missing it.