Ionic Server Side Rendering (SSR)
Guide on the importance of server side rendering and how to add it to your Ionic Angular app
Universal Javascript is JavaScript that can run on the server and in the browser. This is what people refer as “Server Side Rendering” (SSR). By utilizing SSR and Universal JavaScript in our app, we can do an initial render of our app on the server and send over a precompiled version before any JavaScript has been run on the client.
So if SSR can help us, how can we add it to our app? Well up until recently, you couldn’t. A lot of the Ionic components have references to the
window
object and other DOM specific APIs. Since we’re on a server and running in NodeJs, we don’t have access to the DOM. This is where Angular Universal and @ionic/angular-server
module comes in.This feature is only available in PRO version.
A normal Angular application executes in the browser, rendering pages in the DOM in response to user actions. Angular Universal executes on the server, generating static application pages that later get bootstrapped on the client. This means that the application generally renders more quickly, giving users a chance to view the application layout before it becomes fully interactive.
There are three main reasons to add SSR to your app.
- 1.Better SEO rankings (facilitate web crawlers)
- 2.Improve performance (specially on mobile and low-powered devices)
- 3.Preview cards on social media
Google, Bing, Facebook, Twitter, and all social media sites rely on web crawlers to index your application content and make that content searchable on the web. Angular Universal generates a static version of your app that is easily searchable, linkable, and navigable without JavaScript. Universal also makes a site preview available since each URL returns a fully rendered page.
We created a
SeoService
to allow you to change your SEO meta tags dynamically on each route. You can find this service in src/app/utils/seo/seo.service.ts.
If you want to add dynamic meta tags to a specific route you need to add some code on the route resolver.
Travel Listing and Travel Details pages have dynamic meta tags set. Check those resolvers to see how to do it.
For the Travel Listing page we added the following meta tags (you can also add an image):
title = 'Travel Listing Page';
description = 'Travel Description';
keywords = 'travel, keywords, ionic, angular';
travel-listing.resolver.ts
import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, of } from 'rxjs';
import { DataStore } from '../../shell/data-store';
import { TravelService } from '../travel.service';
import { TravelListingModel } from './travel-listing.model';
import { SeoDataModel } from '../../utils/seo/seo-data.model';
@Injectable()
export class TravelListingResolver implements Resolve<any> {
constructor(private travelService: TravelService) {}
resolve(): { dataStore: DataStore<TravelListingModel>, seo: Observable<SeoDataModel> } {
const dataSource: Observable<TravelListingModel> = this.travelService.getListingDataSource();
const dataStore: DataStore<TravelListingModel> = this.travelService.getListingStore(dataSource);
const seo = new SeoDataModel();
seo.title = 'Travel Listing Page';
seo.description = 'Travel Description';
seo.keywords = 'travel, keywords, ionic, angular';
return { dataStore: dataStore, seo: of(seo) };
}
}
For the Travel Details page we set the meta tags dynamically depending on the Data Item. For example, if you visit "Tristan Narvaja" details page, the title of that page would be: "Tristan Narvaja" and the description would be a short description of that place.
travel-details.resolver.ts
import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { DataStore } from '../../shell/data-store';
import { TravelService } from '../travel.service';
import { TravelDetailsModel } from './travel-details.model';
import { map } from 'rxjs/operators';
import { SeoDataModel } from '../../utils/seo/seo-data.model';
@Injectable()
export class TravelDetailsResolver implements Resolve<any> {
constructor(private travelService: TravelService) {}
resolve(): { dataStore: DataStore<TravelDetailsModel>, seo: Observable<SeoDataModel> } {
const dataSource: Observable<TravelDetailsModel> = this.travelService.getDetailsDataSource();
// Typically, SEO titles, descriptions, etc depend on the data being resolved for a specific route
const seoObservable: Observable<SeoDataModel> = dataSource.pipe(
map(data => {
const seo = new SeoDataModel();
seo.title = data.name;
seo.description = data.fullDescription;
seo.keywords = data.category;
return seo;
})
);
const dataStore: DataStore<TravelDetailsModel> = this.travelService.getDetailsStore(dataSource);
return { dataStore: dataStore, seo: seoObservable };
}
}
If you don't define specific meta tags for your routes, the
SeoService
will use the default meta tags. Make sure to update this with your own title, description and image.seo.service.ts
// Set default SEO values here
currentRouteSeoData.title = 'Ionic Full Starter App';
currentRouteSeoData.description = 'Ionic 5 Full Starter App Template - by IonicThemes';
currentRouteSeoData.keywords = '';
currentRouteSeoData.image = 'https://s3-us-west-2.amazonaws.com/ionicthemes/imgs/logofacebook.png';
You can also use this service to set the canonical urls.
Make sure to add your own url to the property
canonicalUrl
from the SeoService.
The service will then add the rest of the url path. canonicalUrl = 'https://YOUR-CANONICAL-URL.com';
Server Side Rendering can really help improve the overall performance of your app. Both the intrinsic performance (optimizing the medium and resources, ie: bandwidth, requests, images, etc) and the perceived performance (how fast the user thinks your website is).
Among others, SSR is great for:
- Less runtime overhead (intrinsic performance)
- Because we serve static html (pre-rendered) and we have instant access to the initial state data for the app
- Because the app was pre-rendered on the server, the user sees the first page quickly
We are happy to announce that after a bumpy ride with previous versions of the framework, latest releases of Ionic Angular successfully support Server Side Rendering through Angular Universal (@ionic/angular-server).
This is a substantial milestone for the framework as SSR was a much awaited feature. It fills the gap of many use cases, performance and user experience.
Upgrading your existing app to support Angular Universal
In our experience, the process is relatively simple once you figure out all the possible bugs that may arise and declutter all the misinformation about what needs to be done to have a properly configured Angular Universal app.
First of all you need to add some dependencies to your project.
ng add @nguniversal/express-engine
npm install --save @ionic/angular-server
npm install --save @angular/animations
# Required by @angular/platform-server (from @ionic/angular-server)
We need to adjust a bit our code so it plays nice with Angular Universal.
You may encounter with a
ReferenceError: XMLHttpRequest is not defined
error.
Somehow Angular Universal has issues if the HttpClient
is included in several modules. The solution is to remove HttpClientModule
from all lazy and eager loaded feature modules and move its import to the app.module.ts
file.By default, when doing request with relative paths from the server, it will throw errors as it's unable to locate those relative paths.
By adding a custom Interceptor, we ensure requests to local assets (i.e.:
./assets/sample-data/fashion/listing.json
) will be converted to absolute paths and work OK from the server.According to the official Angular documentation we need to enable the
initialNavigation
property of the Angular Router (check the app-routing.module.ts
) for server-side rendering to work.When it comes to Server Side Rendering, there are some best practices to take into consideration.
As mentioned before, when we enable server side rendering, we end up bundling two versions of the app.
One that's meant to run on the server (render views through NodeJs and output the Angular bootstrapped app html to the browser) and the other one that will run and get bootstrapped on the browser.
To avoid duplicate work (for example requests to fetch data from an API) it's recommended to use Angular
TransferState
API to seamlessly transition between the server side rendered to the browser rendered app.If you have simple http GET requests, then you can use the
TransferHttpCacheModule
. This module installs an Http Interceptor that avoids doing duplicate requests that were already made when the application was rendered on the server.If you don't implement a
TransferState
strategy you will end up performing an http request in the server, and again, the same request in the browser after transitioning from the server side rendered app.Alternatively, for more advanced use cases, you can use the
TransferState
API directly (basically a key-value store).In our case, we created a
TransferStateHelper
service to help us store data from requests made using our custom DataStore
utility.
We also use this helper service to prevent showing shell elements in SSR to avoid SEO penalties and improve user experience.If we render the app in the server with shell elements, then the SEO crawlers may infer that the page they are indexing doesn't have the content you intend to show and instead get the placeholder elements with no data. This is something you must avoid in terms of SEO strategies.
You can see how we use the
TransferStateHelper
in all the services for the different category pages.The following considerations are particular to this project. They are not part of any Angular official library nor documentation.
As we mentioned earlier, when talking about Progressive Web apps, we follow the App Shell pattern to show some placeholders while loading data. This technique has many benefits including improved user experience while avoiding layout shifts (check out Cumulative Layout Shift documentation to better understand the implications).
We recently added a new configuration to our App Shell
DataStore
utility to enable loading data without prepending a shell.
You can see how we rely on this feature to avoid showing App Shell placeholders when rendering the app in the server in all the services for the different category pages.Assets that are requested once the app html get's rendered in the browser (images, videos) behave differently than text elements that can be requested (through an http request for example) and rendered directly on the server side (and transferred through the network as part of the app's html files).
In particular, in our case we use the
TransferStateHelper
service to mark all the images included in the requested view (rendered in the server) and avoid showing loading animations on those images.
You can see how we put this in action in the <app-image-shell>
component.When loading the app from the server, if you throttle your network speed so that the client-side scripts take longer to download (instructions below), you'll notice that you can click on
routerLink
elements and navigation works correctly. However, user events other than
routerLink
clicks aren't supported. You must wait for the full client app to bootstrap and run, or buffer the events using libraries like preboot, which allow you to replay these events once the client-side scripts load.We didn't include
angular/preboot
in this product. But, It's a pretty straightforward process you can do on your own.When rendering the app in the server, we lack the possibility to inspect the user's browser user-agent which has valuable information about the user's device, OS, etc.
Fortunately, we can still access the information about the user-agent that's requesting the app in the Request user-agent header.
In previous projects we have used this technique for many purposes and believe it could be handy to include an example on how to access Request and Response data from NodeJs in your Ionic/Angular app.
You can see how we send data from the NodeJs server to the Angular client using custom Response headers both in the
server.ts
file and the app.module.ts
(through the APP_INITIALIZER
utility).We should not rely purely on Angular Router wildcard routes to handle non existent navigation routes.
Every time the user navigates to a route that does not exist, the Angular app will respond with the page component defined in that wildcard route, but the server (NodeJS Express) will always return a 200 status code instead of the proper 404 (not found).
In order to handle non existent pages correctly, we rely on the
static-paths.ts
file to configure the server logic that handles missing routes in NodeJs on the server.ts
file.server.ts
// To enable proper 404 redirects in non existent routes we need to specify the existing routes and then
// add a '*' (wildcard) route for all the non existent routes to be treated with a 404 status
APP_ROUTES.forEach(route => {
server.get(`${route}`, (req, res) => {
res.render(indexHtml, {
req,
res
});
});
});
// Properly handle 404's
server.get('*', (req, res) => {
res.status(404).render(indexHtml, {
req,
res,
requestUrl: '/page-not-found'
});
});
Similarly, we should not rely purely on client side Angular Router redirects. Those are not pure redirects, they are emulated redirects because they always return a 200 status code instead of the proper 302 (temporarily redirect) or 301 (permanent redirect).
In order to handle redirects correctly, we rely on the
static-paths.ts
file to configure the server redirection logic in NodeJs on the server.ts
file.server.ts
REDIRECT_ROUTES.forEach((route: ({from: string, to: string})) => {
server.get(`${route.from}`, (req, res) => {
console.log(`GET: ${req.originalUrl}`);
console.log(`Redirecting to: ${route.to}`);
res.redirect(301, route.to);
});
});
This doesn't mean you can remove the Angular Router redirects from your client code. You still need those for views that are requested from the browser rendered app.
The previous technique only applies for views served from the server (typically initial page requests). Subsequent page navigations are typically handled by the browser rendered app.
Last but not least, we should handle missing assets correctly. This means that if the app requests any missing assets, it should return a 404 status code.
If we don't enable this property, by default, the NodeJs Express server will fall through the missing assets to the next middleware, triggering the Angular app wildcard route and a 200 status code instead of the correct 404 status code.
To solve this issue, just enable this simple property in the
server.ts
file.server.ts
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y',
// Avoid static assets errors invoke the next middleware (see: https://expressjs.com/en/4x/api.html#express.static)
fallthrough: false
}));
To test the Angular Universal (SSR) implementation, start a development server with live reload included by running:
npm run dev:ssr
To test the production build, run:
npm run build:ssr && npm run serve:ssr
Testing all the aspects of a Server Side Rendered app can be tricky.
In order to specifically test what the SEO crawlers see when requesting your server side rendered app, you need to disable javascript in the Developer Tools panel.
This will enable you to experience the raw experience of your SSR app, without any interactivity, just plain CSS and HTML.
The transition from the server-rendered app to the client app happens quickly on a development machine, but you should always test your apps in real-world scenarios.
You can simulate a slower network to see the transition more clearly as follows:
- 1.Open the Chrome Dev Tools and go to the Network tab.
- 2.
- 3.Try one of the "3G" speeds.
This way you will be able to observe how the server-rendered app still launches quickly but the full client app may take some seconds to load (due to the network throttling) while giving you the possibility to see how the transition goes.
As a final note, I will be honest and share my opinion and experience after adding SSR to a complete, medium sized app like this template.
Ionic Angular SSR works, but in my opinion it still needs some polishing to become production ready and fulfill its potential.
These are some of the issues I found on the process.
Some are trivial, some have minor importance and would be "nice to have", and a couple are deal breakers for a production app.
Minor visual issues (that can be fixed with workarounds)
Visual issues without workarounds
User experience issues without workarounds
Deal breakers
If you are reading this, please consider up-voting the issues described above to help this move forward.
Maybe it's because I always thought of SSR as the windup feature of any production app, the one that over delivers on the end user experience and helps your projects in non technical related stuff like SEO or perceived performance (which may impact directly on your conversion rates and thus critical for the business).
All the stuff you really start appreciating after the project is deployed and you start seeing your app more through the business lens and less through the technical lens.
Maybe it's because after working several months on a project, built upon cumulative gains, you expect SSR to be a step forward that makes your work shine, and not a drawback.
Maybe it's because I realize the amazing work the Ionic Team has done again and again and thus always demand the best, what they are really capable of.
I have empathy and admiration for the Ionic Team, they are working really hard on so many features that sometimes it's easy to ignore or assume some other feature is not important for the bottom line.
I feel this is the case for server side rendering. They have been slowly working on adding SSR support to the framework and once they reached a semi-stable state, they cheerly announced the big news.
For a demo app, at first glance everything seems to be working fine in regards to SSR, but once you start working on a more complex project and testing real world scenarios, you start finding issues that end up reducing the overall user experience and quality of your app compared to its previous state without SSR.
This may sound harsh, but it's an honest critique from one of the biggest believer and advocate of the Ionic Framework (myself, Agustin). I hope my words get voided by upcoming releases of the framework addressing the issues listed above.
Disclaimer: I tried to be helpful in the process and not just complain or submit bugs, but dig deep in the Ionic source code, analyze the root cause of the issues and by doing so help the Ionic Team reach a diagnosis of the issues easier and faster. Hope you see this post as a constructive critique.
Last modified 1yr ago