-
-
Notifications
You must be signed in to change notification settings - Fork 246
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
Comments
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 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! |
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:
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' |
@questionableservices This is discussed in #444. |
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
@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) |
Yep, this is unfortunately quite difficult. The only workaround I know of is a dynamic segment like In comparison to Next.js, e.g. Remix just lets you define a 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.
Do you by chance have some links? Would be curious to have a look to see if there are edge cases we can consider. |
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 A request for 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, 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? |
It would enable static rendering, yes, but you end up with an invalid 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 |
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 Option with running multiple domains is still the frontrunner for me. It seems cleaner and more in line with both |
@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 I am testing SSG as we speak, and will focus on testing SSR and CSR later. So far so good. |
Oh man, why that complex? |
@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, |
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 However, as we're not there yet, maybe we need EDIT: On a second thought, while it would help to read the locale or tenant, we still require routing support from |
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! |
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 optionalbasePath
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.
/en
→en
,/en-gb
→en-gb
).Users have in various issues expressed the need for further customization where a mapping between a locale and a prefix is necessary:
Examples:
example.com/uk
should useen-gb
example.com/eu
should useen-gb
example.com/eu/en
should useen-gb
example.com/ca/fr
should usefr-ca
example.com/en
should useen-gb
example.com/ar
should usear-u-nu-arab
(i.e. Arabic with the arab numbering system)Relevant issues:
notFound()
behaviour #938This 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:
Prefixes should also be supported in
domains
:Considerations:
uk
is needed in components. The reason is that we'll internally rewrite/uk
to/en-gb
, thereforeuk
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
useen-gb
). Worst case you'd have to read the host/pathname from headers./[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 differentbasePath
(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./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?{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:
new Intl.Locale('ar', {numberingSystem: 'arab'}).toString()
→ar-u-nu-arab
. Also note that theregion
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:basePath
during the build. E.g. in combination withlocalePrefix: 'never'
you can enable URLs likecompany.com/uk
to serve all your pathnames within.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
middlewareE.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).
The text was updated successfully, but these errors were encountered: