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

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.

Angular Universal

Angular Universal is the library used for running our apps on the server.

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.

Benefits of server-side rendering

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

Better SEO rankings & Social Media previews

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.

How to use the SEO service?

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';

Canonical URLs

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';

Improved Performance

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

  • Faster First Contentful Paint (perceived performance)

    • Because the app was pre-rendered on the server, the user sees the first page quickly

Adding Ionic Angular SSR to your app

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.

Install dependencies

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)

Refactor code

We need to adjust a bit our code so it plays nice with Angular Universal.

Angular Universal HttpClient issue

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.

Angular Universal relative path requests Interceptor

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.

Configure the Angular RouterModule for SSR

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.

Best practices

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.

TransferState

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.

DataStore

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.

App Shell

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.

Going the extra mile

Preboot

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.

Mobile Detect

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).

404 status code

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

Server redirects

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.

Properly handle missing assets

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

Test the implementation

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

Developer Tools

Testing all the aspects of a Server Side Rendered app can be tricky.

Testing pure SSR

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.

Testing the transition from SSR to the browser

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. Find the Network Throttling dropdown on the far right of the menu bar.

  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.

Open Issues and Gotchas

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 updated