Language Aware Nuxt.js Routing
For as long as I can remember, building multi-language websites is the norm in Egypt and catering for multi-language audience is an expectation rather than a ānice-to-haveā.
So even if you are starting on a project with a single language it will almost be required at some point to add multi-language support to it.
Localized URLs
In many applications you may be using right now, the user may set their preferred language via a setting that saves it to the cookies ensuring the app can always infer the current language preferred by the user. These types of applications usually do not include the language code in their URLs.
These are not the types of applications Iām talking about in this Article, Iām talking about web apps with URLs like this:
shhttps://example.com/en-US/about
https://example.com/ar-EG/about
For these applications including the language code in the URL is a necessity because it helps each audience able to reach the content they would identify with the most. We tend to call this āsearch engine optimizationā or as commonly refereed to as āSEOā.
Routing in Nuxt.js
We have various ways to redirect the current page in a Nuxt.js application. We have the traditional routing components:
vue<!-- Vue Router Link Component -->
<router-link to="/about">{{ $t('About') }}</router-link>
<!-- Nuxt's Link Component -->
<nuxt-link to="/about">{{ $t('About') }}</nuxt-link>
Aside from that you you have programmatic navigation using vue-router
instance methods:
js// Inside a Vue method or watcher callback...
this.$router.push('/about');
this.$router.replace('/about');
As well as the context.redirect
function that you can access in asyncData
or middleware functions:
jsfunction someMiddleware({ redirect }) {
redirect('/about');
}
Having to specify the correct language code (I will refer to this as locale from now on) for each of these methods is just unwieldy, a naive approach would try to do something like this:
vue<nuxt-link :to="`/${i18n.locale}/about`">About</nuxt-link>
And a similar story for the programmatic navigation:
jsthis.$router.push(`/${this.$i18n.locale}/about`);
this.$router.replace(`/${this.$i18n.locale}/about`);
function someMiddleware({ redirect, app }) {
const locale = app.i18n.locale;
redirect(`/${locale}/about`);
}
Repeating this all over your application is just ridiculous, and while you maybe able to extract the locale-prepending logic to a function or a mixin you still need to ensure to import them all over your application which isnāt exactly very DRY.
You may consider using routing by route name instead of the route path, but that doesnāt work well because Nuxt generates automatic names for your routes based on their path and if you are using something like nuxt-i18n
plugin which is excellent at itās job and I use it all the time, you will end up with hard to predict names that rely on the locale itself, here is an example of the route names generated:
shabout___en-US
about___ar-EG
Which brings you back to square one. In the next sections I will show you some code snippets that I keep using regularly in the multi-language applications I work on and it have served me quite well for a couple of years and Iām sure it will for you.
Language Aware Links
This is the easiest one to handle, instead of thinking about providing the correct path to the nuxt-link
component, letās create a higher order component that does just that. It would re-use the nuxt-link
component after appending the locale code to the path given to it, it would share a similar interface as the nuxt-link
component. Letās call it i18n-link
.
For this component I prefer to implement it as a functional component with render functions, which might increase itās complicity a little bit but such component hardly have any template associated with it.
The hard part would be implementing the render function. Letās review what we need to do, we need to get the to
prop and prepend the locale to the path then render a nuxt-link
component with the newly formed to
prop.
The path could be be an object or a string, for example these are two valid ways to express a path:
shobject: { path: '/some/path' }
string: `/some/path`
First we need access to the current locale, we can do that by accessing the parent
from the render function context (2nd argument), this is because functional components donāt have a this
context.
jsfunction render(h, { parent }) {
// Get the current locale
const locale = parent.$i18n.locale;
}
Next we need to get the current to
prop value and prepend the locale to it, and to make our logic more robust we will check if the locale doesnāt exist in the path so we donāt prepend it twice by mistake. We can access the props provided to the component by grabbing the props
object from the context object.
jsfunction render(h, { parent, props }) {
// Get the current locale
const locale = parent.$i18n.locale;
// The non-localized path
let path = props.to;
// if the URL doesn't start with the locale code
if (!path.startsWith(`/${locale}`)) {
// prepend the URL
path = `/${locale}${path}`;
}
}
To add support for location objects that looks like this { path: '/some/path' }
, we need to add a couple of checks and extract the prepending logic to a function:
jsfunction prependLocaleToPath(locale, path) {
let localizedPath = path;
// if the URL doesn't start with the locale code
if (!localizedPath.startsWith(`/${locale}/`)) {
// prepend the URL
localizedPath = `/${locale}${path}`;
}
return localizedPath;
}
function render(h, { parent, props }) {
// Get the current locale
const locale = parent.$i18n.locale;
const path =
typeof props.to === 'string'
? prependLocaleToPath(locale, props.to)
: {
...props.to,
path: prependLocaleToPath(locale, props.to.path),
};
}
But here is a problem in the prependLocaleToPath
. The problem is if we just exclude the current locale from the localization logic, it prevents us from using URLs with different language codes.
jsif (!localizedPath.startsWith(`/${locale}`)) {
// ...
}
What we need to do is make sure the path isnāt localized with any language code that our app supports, first we modify the prependLocaleToPath
function to accept a locales
array and avoid prepending the locale code if any of the codes exist in the path:
jsfunction prependLocaleToPath(locale, path, locales) {
let localizedPath = path;
// if the URL doesn't start with the locale code
if (
(locales || []).some((loc) =>
localizedPath.startsWith(`/${loc.code}`)
)
) {
// prepend the URL
localizedPath = `/${locale}${path}`;
}
return localizedPath;
}
Then we can easily grab the locales
array from the $i18n
instance:
jsconst locale = parent.$i18n.locale;
const locales = parent.$i18n.locales;
const path =
typeof props.to === 'string'
? prependLocaleToPath(locale, props.to, locales)
: {
...props.to,
path: prependLocaleToPath(
locale,
props.to.path,
locales
),
};
Now that we cleaned this up and supported both types of paths we wanted to, the last step is to render the nuxt-link
component:
jsfunction render(h, { children, data, props, parent }) {
// Get the current locale
const locale = parent.$i18n.locale;
const locales = parent.$i18n.locales;
const path =
typeof props.to === 'string'
? prependLocaleToPath(locale, props.to, locales)
: {
...props.to,
path: prependLocaleToPath(
locale,
props.to.path,
locales
),
};
return h(
'nuxt-link',
{
...data,
props: {
...props,
to: path,
},
},
children
);
}
Note that we grabbed the data
and children
objects from the functional component context, this is because we want to preserve the same slots and props that may have been passed to the nuxt-link
component which is enough for most cases.
Here is the whole component we just built:
jsexport default {
name: 'I18nLink',
functional: true,
render(h, { children, data, props, parent }) {
// Get the current locale
const locale = parent.$i18n.locale;
const locales = parent.$i18n.locales;
const path =
typeof props.to === 'string'
? prependLocaleToPath(locale, props.to, locales)
: { ...props.to, path: prependLocaleToPath(locale, props.to.path, locales) };
return h(
'nuxt-link',
{
...data,
props: {
...props,
to: path
}
},
children
);
}
};
function prependLocaleToPath(locale, path, locales) {
let localizedPath = path;
// if the URL doesn't start with the locale code
if (locales).some(loc => localizedPath.startsWith(`/${loc.code}`))) {
// prepend the URL
localizedPath = `/${locale}${path}`;
}
return localizedPath;
}
Now all you have to do is register this component globally and use it instead of nuxt-link
, you may need to add more features to this component as needed but for most cases.
Now we can easily use this component to route to other parts of the application without having to worry about the current language.
vue<i18n-link to="/">{{ $t('home') }}</i18n-link>
<i18n-link to="/about">{{ $t('about') }}</i18n-link>
Language Aware Navigation
This is a slightly harder problem, because unlike components we cannot cleanly introduce our locale pre-pending logic to router instance methods or the redirect function. This is why we are resorting to āMonkey Patchingā which is one of the oldest methods in JavaScript to override some behavior.
What we are going to do is intercept all Router.push
and Router.replace
calls and pre-pend the locale code, then we call the original methods with the new localized path. To correctly do this, you need to access the Router
class methods before any usage of it comes up, one of the earliest places we can do this is by creating a nuxt plugin.
So go ahead a create a plugins/i18n-routing.js
file.
Monkey patching works best with classes. Fortunately for us, the VueRouter
uses a class for the router, which means we could modify itās prototype and override the original methods. Before we can do that we need to save a reference to the original router methods because we are going to use them later. I will start with the Router.push
method
js// plugins/i18n-routing.js
import Router from 'vue-router';
// Save refs to original router methods.
const routerPush = Router.prototype.push;
Next we need to provide our own method by overwriting the push
method on the router prototype.
jsimport Router from 'vue-router';
const routerPush = Router.prototype.push;
// Override the router.push to localize the new path.
Router.prototype.push = function (...args) {
// TODO: Get current locale
// TODO: Localize the path and call the original `push`
};
To get the current locale, we can use a Nuxt plugin function
jsimport Router from 'vue-router';
const routerPush = Router.prototype.push;
export default function LangAwareRoutingPlugin(ctx) {
// Override the router.push to localize the new path.
Router.prototype.push = function (...args) {
const locale = ctx.app.i18n.locale;
const locales = ctx.app.i18n.locales;
// TODO: Localize the path and call the original `push`
};
}
Now we need to prepend the locale to the path given, which is always going to be the first argument. Then we do exactly the same thing as we did in the i18n-link
render function.
jsimport Router from 'vue-router';
const routerPush = Router.prototype.push;
export default function LangAwareRoutingPlugin(ctx) {
Router.prototype.push = function (...args) {
const locale = ctx.app.i18n.locale;
const locales = ctx.app.i18n.locales;
const path = args[0];
// if the URL doesn't start with the locale code
if (typeof path === 'string') {
// prepend the URL
path = prependLocaleToPath(locale, path, locales);
} else if (typeof path === 'object' && path) {
path = {
...path,
path: prependLocaleToPath(locale, path.path, locales),
};
}
// Make sure we preserve the same API by returning the same result and passing same args
return routerPush.apply(this, [path, ...args.slice(1)]);
};
}
function prependLocaleToPath(locale, path, locales) {
let localizedPath = path;
// if the URL doesn't start with the locale code
if (
(locales || []).some((loc) =>
localizedPath.startsWith(`/${loc.code}`)
)
) {
// prepend the URL
localizedPath = `/${locale}${path}`;
}
return localizedPath;
}
And we can do the exact same thing to the replace
function, and with a little clean up using higher order functions you will end up with something like this:
jsimport Router from 'vue-router';
const routerPush = Router.prototype.push;
const routerReplace = Router.prototype.replace;
export default function LangAwareRoutingPlugin(ctx) {
function withLocalizedPath(fn) {
return function (...args) {
const locale = ctx.app.i18n.locale;
const locales = ctx.app.i18n.locales;
let path = args[0];
// if the URL doesn't start with the locale code
if (typeof path === 'string') {
// prepend the URL
path = prependLocaleToPath(locale, path, locales);
} else if (typeof path === 'object' && path) {
path = {
...path,
path: prependLocaleToPath(locale, path.path, locales),
};
}
return fn.apply(this, [path, ...args.slice(1)]);
};
}
Router.prototype.push = withLocalizedPath(routerPush);
Router.prototype.replace = withLocalizedPath(routerReplace);
}
Now that we are done with programmatic navigation, we can use the router methods to navigate without having to worry about our current locale:
js// Locale code will be added automatically!
this.$router.push('/about');
this.$router.replace('/about');
Finally letās complete our solution by overriding the redirect
function in the Nuxt context, to be able to do that we can do it in a plugin function in the same file we created earlier.
jsexport default function LangAwareRoutingPlugin(ctx) {
// Grab the original function
const redirect = ctx.redirect;
// Override it with our own
ctx.redirect = function localizedRedirect(...args) {
// TODO: prepend URL and call the original
};
// ...
}
The redirect
function is tricky, because it has two non-compatible signatures. You are more likely to use the first one which is more common but for completeness sake we should do both signatures. Here are the two signatures Iām referring to:
js// Just a path
redirect('/about');
// status code and path
redirect(302, '/about');
By detecting the path index in the args, we can easily override the path in-place without affecting either signatures.
jsexport default function LangAwareRoutingPlugin(ctx) {
const redirect = ctx.redirect;
ctx.redirect = function localizedRedirect(...args) {
const locale = ctx.app.i18n.locale;
const locales = ctx.app.i18n.locales;
// figure out which part of the args is the URL as the first argument can occasionally be a status code
const pathIdx = typeof args[0] === 'number' ? 1 : 0;
// localize the path in-place
args[pathIdx] = prependLocaleToPath(
locale,
args[pathIdx],
locales
);
return redirect(...args);
};
// ...
}
And thatās it, you now have language-aware routing in your Nuxt.js application and it would work with your links, programmatic navigation and middleware or validation redirects!
Conclusion
We used higher-order functional components and monkey patching to override the default routing behavior in Nuxt.js
While what I showcased works very well for my use-case, the new vue-router
(v4) composition API does provide an alternate way for the programmatic navigation. Maybe I will write an update for this article when Nuxt 3.0 is out.
You can view a live demo right here in this live codesandbox project