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'));
}
}
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?