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

Integration with multi-tenancy #1107

Open
amannn opened this issue Jun 4, 2024 · 21 comments
Open

Integration with multi-tenancy #1107

amannn opened this issue Jun 4, 2024 · 21 comments
Labels
area: routing enhancement New feature or request

Comments

@amannn
Copy link
Owner

amannn commented Jun 4, 2024

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 #1086, users have expressed the desire to add additional segments like app/[tenant]/[locale].

Related: #1055

(this issue was extracted from #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 #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!

Related discussions

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. #609). Note that you can still use component APIs like useTranslations in this case.

@amannn amannn added enhancement New feature or request unconfirmed Needs triage. area: routing and removed unconfirmed Needs triage. labels Jun 4, 2024
@Gawdfrey
Copy link
Contributor

Gawdfrey commented Jun 4, 2024

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
Copy link
Owner Author

amannn commented Jun 4, 2024

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
Copy link

yaman3bd commented Jun 4, 2024

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
Copy link
Owner Author

amannn commented Jun 5, 2024

@yaman3bd Let's continue the conversation from #532 (reply in thread) 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:

  • A tenant is fetched based on the host header
  • tenant.locale indicates the default locale
  • tenant.supported_locales includes all supported locales of a given tenant

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
Copy link

yaman3bd commented Jun 6, 2024

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
Copy link
Owner Author

amannn commented Jun 6, 2024

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
Copy link

yaman3bd commented Jun 7, 2024

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
Copy link
Owner Author

amannn commented Jun 7, 2024

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
Copy link

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
Copy link
Owner Author

amannn commented Jun 7, 2024

@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.

@Gawdfrey
Copy link
Contributor

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
Copy link

pepijn-vanvlaanderen commented Jun 24, 2024

@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
Copy link

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
Copy link
Owner Author

amannn commented Aug 21, 2024

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
Copy link

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.

@sirajtahra
Copy link

sirajtahra commented Nov 29, 2024

@amannn what about this use case:

  • mydomain.com/saudi-ar
  • mydomain.com/uae-ar
  • mydomain.com/saudi-en
  • my domain.com/uae-en

in this case, both saudi-en & uae-en should use the en-US locale, and saudi-ar should use ar-SA and uae-ar should use ar-AE

so the pattern would be ${country}-${locale} where locale here wouldn't be the full locale for instance?

@amannn
Copy link
Owner Author

amannn commented Nov 29, 2024

@sirajtahra I think custom prefixes could work for this:

import {defineRouting} from 'next-intl/routing';
 
export const routing = defineRouting({
  locales: ['en-US-x-saudi', 'en-US-x-uae', 'ar-SA-x-saudi', 'ar-SA-x-uae'],
  defaultLocale: 'en-US-x-saudi',
  localePrefix: {
    mode: 'always',
    prefixes: {
      'en-US-x-saudi': '/saudi-ar',
      'en-US-x-uae': '/uae-ar',
      'ar-SA-x-saudi': '/saudi-ar',
      'ar-SA-x-uae': '/uae-ar'
    }
  }
});

The one restriction is that prefixes and locales need a 1:1 mapping, therefore I've added a private tag.

@sirajtahra
Copy link

Thank you @amannn for the suggestion 🙏 This should cover my use case

@markomitranic
Copy link

markomitranic commented Dec 4, 2024

@amannn I've tried the new custom prefixes feature, but was unable to set it up correctly. I'd love to get some help understanding it. I applaud the change, but I have to admit that the configuration feels a bit scattered - one needs to read a whole lot of documentation to set everything up, as opposed to it magically working :D

I've been struggling with the following:

  • [da-DK] domain.com
  • [en-DK] domain.com/en-DK/
  • [en-GB] domain.co.uk

In this case the domains configuration would contain an entry much like this one:

{
        "domain": "http://domain.com",
        "defaultLocale": "da-DK",
        "locales": ["da-DK", "en-DK"],
}, 
{
        "domain": "http://domain.co.uk",
        "defaultLocale": "en-GB",
        "locales": ["en-GB"],
}, 
// ...
"localePrefix": "as-needed",

At first I thought it had worked fine, but In reality it seems to display danish pages on the .co.uk website and does weird things with the pathnames (fx /kontakt/ and /contact/ both work on the same site lol).

This used to work perfectly fine before the change (because I made my own middleware back in the day to do custom prefixes, but now I'm struggling to figure out how the domains configuration should work. I've also seen documented usage of localePrefix as an object similar to how we define pathnames but my understanding is that this feature is purely for rewriting prefixes, not necessarily for optional prefixes.

@amannn
Copy link
Owner Author

amannn commented Dec 5, 2024

Hey @markomitranic, hmm that doesn't sound right, yes.

Are you also using custom prefixes since you mentioned that in your comment? But yes, prefixes is only for rewriting prefixes and shouldn't be necessary in case you use locales as-is as prefixes. Can you paste your whole routing configuration so I can make sure we're on the same page?

I'll spin up some tests to look a bit further into this …

As a side note, I really found the combination of localePrefix: 'as-needed' + domains to be quite a difficult one to handle. The docs now have an expandable section on this topic, mentioning tradeoffs that next-intl has to make in order retain the static rendering capabilities of Next.js for this setup. In case it's an option for your app, you can alternatively also set up the routing config based on the domain you're deploying to via an env param.

@amannn
Copy link
Owner Author

amannn commented Dec 5, 2024

@markomitranic I've added #1594 to try to set up a failing test. You think you could help me out there?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: routing enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

6 participants