Angular Universal in Production

4 minute read

One of the projects I’m currently working on requires using Angular Universal server side rendering for SEO optimization. While there are many great articles out there that will help you get started with Angular Universal (I recommend this one if you’re using Angular CLI), there is not much information about using it in an actual production environment. In this post I will talk about some extra things to consider when running Universal in a production environment. Four of the main considerations are:

  • Serving static assets from a CDN and not through the node server
  • Proxying requests to the backend API to avoid the need for CORS
  • Caching
  • Logging

The first consideration is that in a production environment, we don’t want to serve static assets such as images or CSS and JavaScript files through the Universal Node server. We also want to proxy requests to the backend API so that relative paths can be used to make API calls and CORS configuration can be avoided. In development, we use a setup like this to serve static files through the Node server as well as to proxy requests to our backend API to avoid the need for CORS:

// proxy api requests and serve static files if we're in the development environment
if (environment === 'development') {

    // Serve static files from /browser
    app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));

    const proxy = httpProxy.createProxyServer();
    app.all('/api/*', function (req, res) {
        proxy.web(req, res, {
        target: 'test-docker.learning.com/catalogservice',
        secure: false
        });
    });
}

The ‘*.*’ pattern means that any file with any kind of file extension (.css, .js, .png, etc.) will be served from the ‘browser’ directory that is created when the Universal app is compiled and that contains all of the static files for the client side Angular application. The http-proxy NPM package is used to proxy API requests to the backend service.

In Production, we use the Amazon Cloudfront CDN to provide this same behavior. First, we have three Origins set up in our Cloudfront distribution for this Universal app.

  1. A custom origin that points to the host where our Universal Node server is running.
  2. A custom origin that points to the host where our backend API is running.
  3. An S3 origin where our static assets for the client side Universal app are deployed.

Second, we have Behaviors set up with the rules that will forward requests to each of the origins set up above with the following precedence

  1. A behavior with a path pattern of ‘api/*’ that points to our backend API service origin
  2. A behavior with a path pattern of ‘*.*’ that points to the S3 origin containing the client side Angular app
  3. The catch-all ‘*' pattern that points to the Universal Node server origin.

With this setup, the initial request to the app will go to the Universal server and will return the server rendered index.html page. This behavior is also set up with a TTL of 30 minutes so that any subsequent requests for a particular page will be served directly from the edge cache instead of hitting our Universal server. Once the page is loaded in the browser, all requests for static assets and to load the client side application will go to the S3 origin. Finally, any API requests from the client side app will be proxied to our backend API service. This way, all that goes through the Node server is the initial page load request and all static assets are served from the S3 bucket and cached at the Cloudfront edge servers.

Lastly, a consideration for any application in a production environment is to have robust logging for troubleshooting. We use the ELK stack (Elasticsearch, Logstash, Kibana) for all of our log aggregation and monitoring. In our Universal Server we are using the bunyan and bunyan-elasticsearch packages for this. This setup looks like this:

// Set up logger
let logger;
const appName = `Catalog Universal Server - ${environment}`;
if (environment !== 'development') {
const esStream = new elasticsearch({
    indexPattern: '[catalog-universal-]YYYY.MM.DD',
    type: environment,
    host: 'http://elk.ldc.aws'
});
esStream.on('error', function (err) {
    console.log('Elasticsearch Stream Error:', err.stack);
});

logger = bunyan.createLogger({
    name: appName,
    streams: [
    { stream: process.stdout },
    { stream: esStream }
    ],
    serializers: bunyan.stdSerializers
});
} else {
    logger = bunyan.createLogger({ name: appName });
}

Then we setup the bunyan middleware to log basic request data such as request IP address, request length, request duration, etc.

// request logging
app.use(bunyanMiddleware(
    {
        headerName: 'X-Request-Id',
        propertyName: 'reqId',
        logName: 'req_id',
        obscureHeaders: [],
        logger: logger,
        additionalRequestFinishData: function (req, res) {
            return { example: true };
        }
    }
));

Finally, we set up the global error handler to log any errors to bunyan

// Handle errors
app.use((err, req, res, next) => {
    // log the error
    logger.error(err);
    // pass the error to the default express error handler
    next(err);
});

Thanks for reading, hopefully this has given you a few ideas about how to set up an Angular Universal app for production use.