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.

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