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

Customization of prefixes / multi-tenancy #653

Closed
6 tasks
amannn opened this issue Nov 22, 2023 · 13 comments · Fixed by #1086
Closed
6 tasks

Customization of prefixes / multi-tenancy #653

amannn opened this issue Nov 22, 2023 · 13 comments · Fixed by #1086

Comments

@amannn
Copy link
Owner

amannn commented Nov 22, 2023

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

Currently, next-intl supports prefix-based routing (e.g. /en), domain-based routing (e.g. domain.co.uk) as well as a combination of both. Additionally, an optional basePath is automatically considered.

What is required, however, is that if a locale shows up in the URL, it is expected to be the same locale that the app uses internally (e.g. /enen, /en-gben-gb).

Users have in various issues expressed the need for further customization where a mapping between a locale and a prefix is necessary:

Examples:

  1. example.com/uk should use en-gb
  2. example.com/eu should use en-gb
  3. example.com/eu/en should use en-gb
  4. example.com/ca/fr should use fr-ca
  5. example.com/en should use en-gb
  6. example.com/ar should use ar-u-nu-arab (i.e. Arabic with the arab numbering system)

Relevant issues:

This affects the rewrites and redirects in the middleware, as well as the navigation APIs. Note that other features like useTranslations aren't affected.

We should at least provide a guide or alternatively built-in support in case this is arguably hard to achieve in userland.

There might still be cases that we don't handle, so we should also add docs for how to add your own middleware and routing APIs while still being able to use all component-based APIs from next-intl.

Describe the solution you'd like

next-intl relies on a [locale] param, therefore a mapping needs to be created for these cases.

The middleware already provides rewrites to a) hide locale prefixes and b) localizing pathnames. Rewrites seem to be a good option for this use case and allow for a lot of flexibility (see also Segmented Rendering by Eric Burel). This pattern requires thinking in terms of external and internal pathnames, but the i18n use case as well as multi-tenancy seems like a reasonable use case for this.

We could create a mapping between locales and prefixes, by accepting an object in all places where a locale can be configured in routing APIs:

// Provides routing for:
// `/*`: `en-us`
// `/eu/*`: `en-gb`
// `/ca/fr/*`: `fr-ca`
createMiddleware({
  locales: ['en-us', {prefix: '/eu', locale: 'en-gb'}, {prefix: '/ca/fr', locale: 'fr-ca'}}],
  defaultLocale: 'en-us',
  localePrefix: 'as-necessary'
})

Prefixes should also be supported in domains:

createMiddleware({
  locales: ['sv-se', 'nb-no', 'en-gb'],
  defaultLocale: 'en-gb',
  localePrefix: 'as-necessary',
  domains: [
    {
      domain: 'example.se',
      defaultPrefix: 'sv-se',
      locales: ['sv-se', {prefix: '/en', locale: 'en-gb'}]
    },
    {
      domain: 'example.no',
      defaultPrefix: 'nb-no',
      locales: ['nb-no', {prefix: '/en', locale: 'en-gb'}]
    }
  ]
})

Considerations:

  • Users might have to map a locale back to a prefix if e.g. the value uk is needed in components. The reason is that we'll internally rewrite /uk to /en-gb, therefore uk can't be read as a param. This could be problematic when multiple prefixes map to the same locale (e.g. both /eu as well as /uk use en-gb). Worst case you'd have to read the host/pathname from headers.
  • Additional segments like /[market]/[locale] or /[tenant]/[locale] are still not supported. Workaround: In case the dynamic values are known ahead of time, you can build each one of them separately with a different basePath (see below). We should maybe document this as an escape hatch. As for dynamic segments, the workaround would be to implement the middleware and navigation APIs in userland.
  • It's an open question wether /eu/de should be a rewrite to e.g. /de-DE or if the market segment could be made available in Next.js too. Users might need this to make market-specific adaptions. Maybe in the end we could make it easier to support cases where there's a mapping necessary between locales and prefixes, but if something more custom is needed, the user should implement the middleware and navigation APIs in userland?
  • Maybe in a second step, we could support prefixes like {locale: 'en', prefix: '/[market]/en'}. I think this would require Reading params deeply in Server Components (e.g. for i18n / multi-tenancy) vercel/next.js#58862 too though.

TODO:

  • Research how Astro tackles this with custom locale paths
  • Adapt middleware (map external pathnames to internal ones)
  • Adapt routing APIs (map internal pathnames to external ones)
  • Docs:
    • New supported config
    • Improve docs for locales: How can locales be constructed? E.g. new Intl.Locale('ar', {numberingSystem: 'arab'}).toString()ar-u-nu-arab. Also note that the region of a locale can be a region of the world where the language isn't natively spoken, e.g. to inherit other properties (e.g. en-th uses English and the buddhist calendar as used in Thailand).

Describe alternatives you've considered

Today, users have the following options:

1) Deploying separate markets with a different basePath

By using an env param like NEXT_PUBLIC_MARKET=UK, you can use this option during the build and runtime to transform certain aspects of your app:

  1. Setting a basePath during the build. E.g. in combination with localePrefix: 'never' you can enable URLs like company.com/uk to serve all your pathnames within.
  2. The env param can be used in app code e.g. for customization of the logo.

You can now have a separate deployment per market and link these together with a reverse proxy.

2) Reimplement the middleware and navigation APIs

See e.g. #609. Note that you can still use component APIs like useTranslations in this case.

There's also https://github.com/labd/next-intl-custom-paths which is userland implementation of the proposed feature here (haven't tried it yet).

3) Rewrite the URL before passing it to the next-intl middleware

E.g. #549 (comment). Note that you have to reimplement alternate links in this case too (if needed) and also provide your own navigation APIs.

Further context

This is closely related to #444, maybe these two should be implemented together.

Somewhat related to this, I recently saw the Remix RFC for middleware. It's pretty cool how composable it is. I'm wondering if Next.js will consider something like this in the future too, e.g. implementing auth + i18n is already a common use case where this could help. In case Next.js decides to add another middleware API, we might be able to offer different parts that can be chained together independently (e.g. handle auth → resolve market → resolve locale).

@cprussin
Copy link

For what it's worth, we have this issue at my work (specifically we are migrating from a legacy app to next.js, and the legacy app already used language codes that are not valid bcp47 codes in the path structure, so we had to match that legacy structure and map the codes to the actual locale codes).

Here's how we solved it:

// locale.ts

import { useLocale as useNextIntlLocale } from "next-intl";

export const DEFAULT_LOCALE = "en-US";
export const SUPPORTED_LOCALES = [
  "en-AU",
  "en-CA",
  "en-GB",
  "en-US",
  "es",
  "fr-CA",
  "fr-FR",
  "ja-JP",
] as const;

export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const isSupportedLocale = (val: unknown): val is Locale =>
  typeof val === "string" &&
  (SUPPORTED_LOCALES as readonly string[]).includes(val);

// Map locales in the path to supported BCP-47 locale tags.  Ideally we'll
// eventually migrate to this just being a passthrough, but we have a lot of
// tags that are used in the URL that aren't BCP-47 compliant so we need this to
// map between them.
export const pathLocaleToSupportedBcp47LocaleMap: Record<string, Locale> = {
  en: "en-US",
  au: "en-AU",
  ca: "en-CA",
  es: "es",
  "fr-ca": "fr-CA",
  fr: "fr-FR",
  jp: "ja-JP",
  uk: "en-GB",
};

export const PATH_LOCALES = Object.keys(pathLocaleToSupportedBcp47LocaleMap);

export const pathLocaleToSupportedBcp47Locale = (
  pathLocale: string,
): Locale | undefined =>
  pathLocaleToSupportedBcp47LocaleMap[pathLocale.toLowerCase()];

export const supportedBcp47LocaleToPathLocale = (
  bcp47Locale: Locale,
): string => {
  const match = Object.entries(pathLocaleToSupportedBcp47LocaleMap).find(
    (entry) => entry[1] === bcp47Locale,
  );
  if (match) {
    return match[0];
  } else {
    throw new Error(
      `Expected to find ${bcp47Locale} in path locale to bcp47locale map but no such entry was found!`,
    );
  }
};
// middleware.ts

import { NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";

import {
  SUPPORTED_LOCALES,
  DEFAULT_LOCALE,
  pathLocaleToSupportedBcp47Locale,
} from "./locale";
import { RUNNING_IN_CLOUD } from "./server-config";

const intlMiddleware = createIntlMiddleware({
  locales: SUPPORTED_LOCALES,
  defaultLocale: DEFAULT_LOCALE,
  localePrefix: "as-needed",
  // TODO this disables automatic locale detection on un-prefixed routes based
  // on a cookie / matching Accept-Languages.  I (cprussin) strongly believe we
  // should turn this on but that's something to revisit later
  localeDetection: false,
});

const intlRegex = new RegExp("/((?!.*\\..*).*)");

const middleware = async (request: NextRequest) =>
  intlRegex.test(request.nextUrl.pathname)
    ? intlMiddleware(handlePathLocale(request))
    : NextResponse.next();

const handlePathLocale = (request: NextRequest): NextRequest => {
  const pathLocale = request.nextUrl.pathname.split("/")[1];
  const bcp47Locale = pathLocaleToSupportedBcp47Locale(pathLocale ?? "");
  if (pathLocale && bcp47Locale) {
    const mappedURL = new URL(
      request.nextUrl.pathname.replace(
        new RegExp(`^/${pathLocale}`),
        `/${bcp47Locale}`,
      ),
      request.nextUrl.origin,
    );
    return new NextRequest(mappedURL, request as Request);
  } else {
    return request;
  }
};

export const config = {
  matcher: "/((?!api|_next|monitoring|_vercel).*)",
};
// navigation.tsx

import { createSharedPathnamesNavigation } from "next-intl/navigation";

import {
  type Locale,
  PATH_LOCALES,
  DEFAULT_LOCALE,
  supportedBcp47LocaleToPathLocale,
  useLocale,
} from "./locale";

export { useSearchParams } from "next/navigation";

const {
  Link: NextIntlLink,
  usePathname: useNextIntlPathname,
  useRouter: useNextIntlRouter,
} = createSharedPathnamesNavigation({
  locales: PATH_LOCALES,
});

export const Link = ({
  locale,
  ...props
}: Parameters<typeof NextIntlLink>[0] & { locale?: Locale | undefined }) => {
  const currentLocale = useLocale();
  const bcp47Locale = locale ?? currentLocale;
  return (
    <NextIntlLink
      {...props}
      locale={
        bcp47Locale === DEFAULT_LOCALE
          ? undefined
          : supportedBcp47LocaleToPathLocale(bcp47Locale)
      }
    />
  );
};

export const usePathname = () => {
  const pathname = useNextIntlPathname();
  const pathLocale = PATH_LOCALES.find((locale) =>
    pathname.startsWith(`/${locale}`),
  );
  return pathLocale
    ? pathname.replace(new RegExp(`^/${pathLocale}/?`), "/")
    : pathname;
};

export const useRouter = () => {
  const currentLocale = useLocale();
  const router = useNextIntlRouter();
  return {
    ...router,
    push: (
      href: Parameters<typeof router.push>[0],
      options:
        | (Parameters<typeof router.push>[1] & { locale?: Locale })
        | undefined,
    ) => {
      router.push(href, mapToPathLocale(currentLocale, options ?? {}));
    },
    replace: (
      href: Parameters<typeof router.replace>[0],
      options:
        | (Parameters<typeof router.replace>[1] & { locale?: Locale })
        | undefined,
    ) => {
      router.replace(href, mapToPathLocale(currentLocale, options ?? {}));
    },
    prefetch: (
      href: Parameters<typeof router.prefetch>[0],
      options: Parameters<typeof router.prefetch>[1] & { locale?: Locale },
    ) => {
      router.prefetch(href, mapToPathLocale(currentLocale, options));
    },
  };
};

const mapToPathLocale = <T extends { locale?: Locale }>(
  currentLocale: Locale,
  { locale, ...options }: T,
): Omit<T, "locale"> & { locale?: string } => {
  const bcp47Locale = locale ?? currentLocale;
  return { ...options, locale: supportedBcp47LocaleToPathLocale(bcp47Locale) };
};

We've put a fair amount of mileage on it and it seems to work well, but I'm certain this could be implemented a bit cleaner (especially the navigation.tsx part) and I wouldn't be surprised if there are edge cases that we haven't run into that aren't handled properly.

Anyhow, hopefully that helps. I'm happy to do whatever I can, including putting in PRs, to help get some functionality built in to next-intl here, as I'd love the code to not have to live userland!

@questionableservices
Copy link

Hello and Happy New Year!

Can you possibly implement a flag in the config so that even if localePrefix is set to 'as-needed', the locale prefix for the defaultLocale would not render in the initial render?

I am guessing that the current implementation that causes 307 redirects for crawls might negatively impact the SEO of the website.

As an example, using pathnames:

export const pathNames = { '/': '/', '/about-us': { en: '/about-us', de: '/uber-uns', }, '/services/[slug]': { en: '/services/[slug]', de: '/dienstleistungen/[slug], }, }

What I would like to see is that at the initial render the links would be 'about-us' or '/de/uber-uns'. At the moment, with localePrefix set with 'as-needed' they render as '/en/about-us' or '/de/uber-uns'

@amannn
Copy link
Owner Author

amannn commented Jan 23, 2024

@questionableservices This is discussed in #444.

@markomitranic
Copy link

I like the solution proposed here and I think it will provide a huge degree of flexibility to the package. This stuff is commonplace in other ecosystems and I am super happy to see a serious attempt at it here.

I am, however, a worried about how domains play with Link and SSG. I am also worried that we will never be able to leave redirects and use client behind, when it comes to Link. With the proposed solution, any code that wishes to construct a URL must now know both the domain and the locale.

  1. SSG doesn't support per-domain generation. I'm pulling my hair out over this. This is becoming a serious deal-breaker for me as I see no way out of it with the current next-intl or in the future.
  2. In SSR and CSR, the domain information would need to be cached along with the locale information, in cache and contexts.
  3. We'd have to fix the Link component to automatically use absolute URLs for domain changes, or somehow encode the target domain and locale into the redirection URL.

@amannn any thoughts on this are appreciated, especially to number 1, as 2&3 are doable but breaking (I already did em for my projects with a custom middleware)

@amannn
Copy link
Owner Author

amannn commented Jan 23, 2024

SSG doesn't support per-domain generation

Yep, this is unfortunately quite difficult. The only workaround I know of is a dynamic segment like app/[domain] but it seems a bit backward having to provide complicated rewrites for such a thing.

In comparison to Next.js, e.g. Remix just lets you define a cache-control response header, allowing to get the performance benefits of SSG at the CDN level. Personally, I think this is quite helpful for these edge cases and I wish Next.js would support this too. I could imagine an explicit cache-control header with x-forwarded-for being part of the vary header could work for this use case. Maybe you want to join the discussion at vercel/next.js#58110.

The alternative I'm currently seeing here is doing a separate build per domain, optionally making the domain available as an env param that can be read in components.

This stuff is commonplace in other ecosystems

Do you by chance have some links? Would be curious to have a look to see if there are edge cases we can consider.

@markomitranic
Copy link

Thanks for the answer :)

Ok glad to hear that I'm not crazy 🗡️

I am currently working on rewriting the middleware, to support the following setup, that I suspect might work with SSG.

#789 gave me the idea to use the middleware as a reverse-proxy - to treat next-intl locale strings as values that encode both the url and the locale.

A request for https://example.dk/en/test would get internally rewritten to /example.dk_en/test and my [locale] would pick up example.dk_en (or perhaps urlencoded version) which I can then split("_") and map to a domain and locale if needed.

This way, SSG would allow me to pregenerate everything, as well as SSR would work. (I still hate that I have to run middleware all the time, but c'est la vie)

Of course there is more work there to be done - html lang needs to be constructed, Link needs to get updated to understand this, probably hreflang headers as well, It'd require a middleware rewrite.

What do you think about it? Would SSG work or am I totally out of my depth here? Perhaps if it ends up working for me, it may be a first step to resolving this in the wider library?

@amannn
Copy link
Owner Author

amannn commented Jan 23, 2024

It would enable static rendering, yes, but you end up with an invalid locale parameter that requires deserialization. If you're rewriting the whole middleware & navigation APIs anyway, you could implement it via two segments like [domain]/[locale] so that the locale can still be cleanly read by next-intl (see also #663).

I still hope that Next.js will provide better support for this in the future.

Using a separate build per domain is not an option for you I suppose? I had this idea the other day and think the big advantage here is that you can use all current APIs of next-intl without having to reimplement anything.

@markomitranic
Copy link

markomitranic commented Jan 23, 2024

I originally wished to separate them, but then I'd also have to rewrite the context and the cache mechanism to support 2 properties instead of a single locale. This way initially seemed simpler, but I guess, at this point it isn't too much of a difference.

Option with running multiple domains is still the frontrunner for me. It seems cleaner and more in line with both next and next-intl. I would still have to rewrite Link #788 (reply in thread) (because 1. it is perfectly capable of being SSR 2. it must provide absolute paths instead of redirections as next-intl redirections don't fully work with domains). On top of that, I'm a bit worried about running 20 domains - Need to set up 20 Sentrys, running 20 APIs for no reason etc.

@markomitranic
Copy link

@amannn I followed the middleware step by step, and dropped a bunch of functionality that I personally don't need (for the sake of simplicity of this POC) and ended up with:

export default function createIntlMiddleware(intlConfig: IntlConfig) {
  return (request: NextRequest): NextResponse => {
    /**
     * The currently requested domain
     */
    const domain = resolveDomainDefinition(
      intlConfig.domains ?? [],
      request.nextUrl.host,
    );

    /**
     * The target locale of the request.
     * Based on the path prefix and the domain locales.
     */
    const locale =
      resolveLocaleFromPrefix(domain.locales ?? [], request.nextUrl.pathname) ??
      domain.defaultLocale;

    /**
     * Internal pathname as Next.JS expects to see it.
     * fx. `/en/contact` -> `/en-GB/contact`
     */
    const internalPathname = resolveInternalPathname(
      request.nextUrl.pathname,
      locale,
      intlConfig.pathnames,
    );

    const url = request.nextUrl.clone();
    url.pathname = internalPathname;

    return NextResponse.rewrite(url);
  };
}

Seems to work fine so far. Allows me to have runtime multi-tenancy. I made some limiting choices for the sake of simplicity, for example "locales are unique, languages aren't" but this can be expanded to fit all of next-intl functionality.

I am testing SSG as we speak, and will focus on testing SSR and CSR later. So far so good.

@SalahAdDin
Copy link

SUPPORTED_

Oh man, why that complex?

@yaman3bd
Copy link

@amannn I love the idea next-intl was built with multi-tenancy support in mind, but I could not find any docs or issues explaining how should I set it up with multi-tenancy, could you help me, please?

I am still on Pages Router, each tenant content is fetched based on the request host, everything is dynamic so I can not use SSG, I have to fetch everything from SSR, getServerSideProps, and locals fetched from external API not imported from public dir. could you help me please how would I use next-intl in this case?

@amannn amannn changed the title ☂️ Umbrella: Customization of prefixes / multi-tenancy Customization of prefixes / multi-tenancy Mar 28, 2024
@amannn
Copy link
Owner Author

amannn commented Apr 26, 2024

While working on #1017 I noticed that if Next.js would support a way to read params deeply, we could enable the user to match any kind of URL structure to a locale to be used in the app.

This would be a really good long-term solution to this problem, even to read the [locale] param in the default setup.

However, as we're not there yet, maybe we need prefix as a stopgap solution.

EDIT: On a second thought, while it would help to read the locale or tenant, we still require routing support from next-intl.

@amannn
Copy link
Owner Author

amannn commented Jun 4, 2024

Good news, I've added built-in support for custom prefixes in #1086!

There's a pre-release available, so if you'd like to give this a spin and provide feedback, that would be much appreciated! 🙏

Please note that the API has changed a little bit from the initial draft in this issue, please see the PR for details.

I've also extracted multi-tenancy to a separate issue: #1107. Therefore this issue will be closed once custom prefixes are merged. Also note that custom prefixes are currently configured app-wide, a domain-based setting is being evaluated in #1055 — if this is interesting to you, please leave a thumbs up there!

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

Successfully merging a pull request may close this issue.

6 participants