amannn/next-intl

Customization of prefixes / multi-tenancy

amannn opened this issue · 13 comments

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

Currently, next-intl supports prefix-based routing (e.g. /en) as well as domain-based routing (e.g. domain.co.uk).

Users have in various issues expressed the need for further customization, e.g.:

  1. Choosing a different prefix than the locale that is used (e.g. example.se/en should use en-se, see #609, #549)
  2. Adding further prefixes like [market]/[locale] (e.g. https://www.motatos.at/ vs https://www.matsmart.se/)
  3. Static prefixes (e.g. theme/[locale], see #650 — however this might be covered by #243)

Describe the solution you'd like

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 too hard to achieve in userland. In either case, it might make sense to start with a guide and possibly develop that into a built-in solution at a later point once we have confidence in the solution.

Generally, the middleware already provides rewrites to a) not show a prefix for the default locale and b) localizing pathnames.

Rewrites seem to be good option for this use case and allow for a lot of flexibility (see 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.

Describe alternatives you've considered

Today, users can either:

  1. Rewrite the URL before passing it to the next-intl middleware (e.g. #549 (comment))
  2. Reimplement the middleware entirely (e.g. #609)

Note that also new navigation APIs need to be added in your app in these cases.

Relevant issues

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!