Sekrab Garage

Twisting Angular localization

Using Angular APP_BASE_HREF token to serve multilingual apps, and hosting on Netlify

Angular August 15, 22
Subscribe to Sekrab Parts newsletter.

Previously, we worked out a way to generate the HTML pages ahead of time, and this will be very beneficial in cloud hosting. In this article, we shall push our multilingual single-build Angular application to Netlify client-only hosting and watch it sink or float.

anchorNetlify

To host on Netlify a browser-only app, all files must sit in the same folder, including any external configuration. We first make our angular.json copy all necessary assets to the host folder. On StackBlitz find the example build under /host-netlify

In our angular.json, let’s adapt the writeindex task as well:

// in writeindex target, the destination should also be the same folder
// if you are using gulp tasks, you need to adjust options as well
"writeindex": {
  "builder": "./builder:localizeIndex",
  "options": {
		// source from netlify and place in same folder
    "source": "host/client/placeholder.html",
    "destination": "host/client",
   // ...
},

Our package.json will include only two tasks

"build": "ng build --configuration=production",
"postbuild": "ng run cr:writeindex"

With netlify installed globally, move into host output folder, and run netlify dev to test locally. First, the cookie driven solution:

anchorCookie driven apps

Netlify uses a special cookie to identify Language: nf_lang, this comes really handy in this setup. The netlify.toml then looks like this:

# the publish folder
[build]
  publish = "client/"

# netlfy.toml
[[redirects]]
  from = "/*"
  to = "/index.ar.html"
  status = 200
  # condition, cookie nf_lang set to "ar"
  conditions = {Language = ["ar"]}

# all other languages, default to "en"
[[redirects]]
  from = "/*"
  to = "/index.en.html"
  status = 200

In our config (or external configuration), set the cookie name to nf_lang

// src/app/config.ts
export const Config = {
  Res: {
    cookieName: 'nf_lang',
		//...
  },
};

Running locally, switching languages, works. Moving on. I wish life was always this easy.

anchorURL driven apps

After building with base href set to the right language (for example /en/), we need to rewrite all assets to root folder:

/en/main.x.js/main.x.js

This is a rewrite rule, not a physical serve. But how do we set it apart from friendly URLs? /en/products for example? There are three ways:

  • Manually place rules for specific routes, then a general rewrite for the rest. This is the least efficient, so I will not dig into it.
  • Using Netlify query rule to distinguish assets from friendly URLs
  • Relying on Angular itself to do the heavy lifting, using APP_BASE_HREF token, this is my preferred option

Filtering by query

We can distinguish URLs sent in every navigation request with a query string, for example: nf_route

In our netlify.toml

# redirect anything with query nf_route

# plain redirects need to be handled first
[[redirects]]
  from = "/en"
  to = "/index.en.url.html"
  status = 200

[[redirects]]
  from = "/ar"
  to = "/index.ar.url.html"
  status = 200

# for every url with nf_route in query, redirect back to root
[[redirects]]
  from = "/en/*"
  to = "/index.en.url.html"
  status = 200
  query = {nf_route = "1"}

[[redirects]]
  from = "/ar/*"
  to = "/index.ar.url.html"
  status = 200
  query = {nf_route = "1"}

# catch resources and rewrite, this will fail if url is /en/products without query
[[redirects]]
  from = "/:lang/*"
  to = "/:splat"
  status = 200

# if nothing matches redirect root to language cookie
[[redirects]]
  from = "/*"
  to = "/ar/"
  status = 301
  conditions = {Language = ["ar"]}

# default redirect
[[redirects]]
  from = "/*"
  to = "/en/"
  status = 301

Browsing to /en serves the index.en.url.html, and all other static resources under /:lang/* rule. A friendly URL like /en/products/details?nf_route=1 will be served with index.en.url.html as well. To make that work, we need to append nf_route=1 to all Angular routes. One way to do that is add a NavigationEnd event listener, and use Location.go, which replaces the URL without refreshing.

In root component, or App module:

export class AppModule {
  // Location from @angular/common
  constructor(router: Router, location: Location) {
    router.events
      .pipe(filter((event) => event instanceof NavigationEnd))
      .subscribe({
        next: (e: NavigationEnd) => {
          // for netlify query trick, add nf_route for all navigation
      	  if (router.url.indexOf('nf_route=') < 0 ) {
            // append it if it's not present
            location.go(router.url, 'nf_route=1');
          }
        },
      });
  }
}

One use case might slip momentarily, and that is when we browse to a friendly URL that has no query. The result is a 404.html page. Netlify hosting catches that with a custom 404.html page. We can make use of that to redirect via JavaScript, if it is not an asset.

<!-- in host/client, add a 404.html page -->
<!DOCTYPE html>
<html>
<body>
    <script>
      if (window.location.href.indexOf('.') < 0) {
        // do nothing if it's a resource file, else add nf_route and replace
        window.location.replace(window.location.href + '?nf_route=1');
      }
    </script>
</body>
</html>

This also means we need to add the 404.html to the assets array in angular.json. This solution feels like a bit of a hack. Let’s move on to a better solution.

Angular APP_BASE_HREF

The other solution is to rely on Angular to provide the language prefix in URL, without changing the base href of the document. To do that, we add the APP_BASE_HREF token to our root providers, the value of which is reliant on the external language script.

// in root app.module 
@NgModule({
  // ...
  providers: [
    { provide: LOCALE_ID, useClass: LocaleId },
    // here, provide APP_BASE_HREF, this will make URL based solution
    // use value saved in Res class
    { provide: APP_BASE_HREF, useValue: '/' + Res.language }
  ],
})
export class AppModule { }

This makes the app work with base href set to / and all routes are prefixed by the language provided by the script present in index.[lang].html. (In development, that is always the default language /en.)

To use this method, the following hosting setup is needed:

# netlify.toml with URL driven solution using APP_BASE_HREF token
# redirect anything with en or ar in root
# use the index file that has no language in the base href
[[redirects]]
  from = "/en/*"
  to = "/index.en.html" # not index.en.url.html
  status = 200

[[redirects]]
  from = "/ar/*"
  to = "/index.ar.html"
  status = 200

# if nothing matches redirect root to language cookie
[[redirects]]
  from = "/*"
  to = "/ar/"
  status = 301
  conditions = {Language = ["ar"]}

# default redirect
[[redirects]]
  from = "/*"
  to = "/en/"
  status = 301

All assets are relative to root, and are served statically. Angular routes are rewritten. This is a great solution.

APP_BASE_HREF in SSR

We almost never need it in SSR, even if we opt in serverless functions, but let’s see if the above solution holds in SSR. We faced a similar issue with LOCALE_ID, the value accesses the script too soon, and it needs to be updated upon routing. So instead of useValue we use useClass and extend the String class:

// in app.module 
{ provide: APP_BASE_HREF, useClass: RootHref }

Somewhere in our application (find it in StackBlitz src/app/core/res.ts)

// The RootHref class extends String
export class RootHref extends String {
  // for browser platform this needs to be in constructor
  constructor() {
    super('/' + (cr.resources.language || 'en'));
  }
}
Note: when I placed it in toString method, it did not work in browser platform. I ran out of candles so I did not dig any deeper!

Loose end

Since we have no server middleware to save the chosen language in a cookie, user's choice might be lost upon revisiting. A click event handler is enough to fix that:

// in html template: 
`(click)="saveLanguage(language.name)" 
 [href]="getLanguageLink(language.name)"`
// in component code, save cookie for next time
saveLanguage(lang: string) {
   this.setCookie(lang, Config.Res.cookieName, 365);
}

Hosting on browser-only Netlify was worth it. Let’s invite others to the party.

anchorHosting on Firebase and Surge

I started writing the host configuration for Firebase thinking it had more to offer, and for Surge thinking it would never work. But I was surprised on both accounts. Next episode of this long series to twist Angular localization will be dedicated to Firebase and Surge. 😴

Thank you for reading this far, have you fallen into the trap of revisiting old code like I have?

  1. two years ago
    Alternative way to localize in Angular
  2. two years ago
    Serving multilingual Angular application with ExpressJS
  3. two years ago
    Serving the same Angular build with different URLs
  4. two years ago
    Serving a different index.html in an Angular build for different languages
  5. two years ago
    Currency Angular pipe, UI language switch, and a verdict
  6. two years ago
    Pre-generating multiple index files using Angular Builders and Gulp tasks to serve a multilingual Angular app
  7. two years ago
    Using Angular APP_BASE_HREF token to serve multilingual apps, and hosting on Netlify
  8. two years ago
    Multilingual Angular App hosted on Firebase and Surge with the same build
  9. two years ago
    Alternative way to localized in Angular: Conclusion and final enhancements