HOW-TO: Serve Angular Universal with an existing Express server

The goal of this guide is to explain how you can add an Angular Universal app to an existing Express server, including some potential pitfalls you may encounter along the way.

If you're unfamiliar with the term, Angular Universal is the Angular-blessed way to do SSR or server-side rendering with Angular applications. SSR is great for getting faster initial loads and serving reasonable things to web crawlers. The downside is that the deployment is more complicated. Instead of your frontend just being a series of static files, you need to deploy and maintain a server.

If you're like us, though, we already had an Express server for our API, and Express is one of the supported servers for serving SSR. Well this guide will explain how you can leverage your existing Express server to serve your Angular Universal App.

Project Setup

Before we get into the details of how to set this up, I think it'll be worthwhile to take a brief aside about our project setup.

At the top level this is our directory structure:

  • api/  - defines our entire api, has a runnable api server, exports a function to register all its api handlers onto a provided Express app

  • web/ - This is our whole Angular app, ⇐ the goal of this post is to get our angular app to export ssr handlers.

  • server/ - a single file, whose sole responsibility is to create the Express Server and register the handlers for the API and the Angular Universal app.

Each one of these directories has their own package.json file which allows us / requires us to manage dependencies separately across the server and frontend.

Note: Because we’re trying to isolate our dependencies between the frontend and backend, our approach is to build the Angular Universal app first, and reference the compiled JavaScript from our Server. If we didn’t do that we’d need to leak a bunch of Angular dependencies into our server.

Angular's server.ts File

If you've added Angular Universal to your project, you've probably seen that it adds several files to your project one of which is called server.ts. This file is the main entry point for Angular Universal's Server. It's the one that actually starts listening for requests. The app function, in particular, is important to the resolution of this problem so let's take a moment to understand what it's doing at a high level:

// The Express app is exported so that it can be used by serverless Functions.
export function app() {
  const server = express();
  const distFolder = join(process.cwd(), 'dist/ng-universal-test/browser');
  const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
 
  // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
  server.engine('html', ngExpressEngine({
    bootstrap: AppServerModule,
  }));
 
  server.set('view engine', 'html');
  server.set('views', distFolder);
 
  // Example Express Rest API endpoints
  // app.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(distFolder, {
    maxAge: '1y'
  }));
 
  // All regular routes use the Universal engine
  server.get('*', (req, res) => {
    res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
  });
 
  return server;
}

This function:

  • creates an Express app

  • constructs a path to the the static version of the app

  • registers the Angular Universal handlers as the Express template engine

  • registers all "file-like", (e.g. “icon.png”) requests as static file handlers

  • registers all other requests to be handled by the Angular Universal template engine.

So this exported function is pretty much doing all the work that we need to register our SSR handlers. The only issue is that it’s currently creating the Express app itself. With a couple of modest changes to our server.ts file we should be able to get the behavior we want. Let’s take a look.

Importing a modified version of the app function from the compiled Angular App

Instead of creating the Express app and static file directory in the body of the app() function, we'll add them as arguments (and delete the lines creating the Express app and distFolder):

export function app(server, distFolder) {

Next, we'll import the app function from the compiled Angular Universal main.js file. Then, the server just needs to pass its existing Express app, and the location of the static files into the app() function:

const { app } = require('../../../ng/dist/ng-universal-test/server/main');
 
const baseServer = express();
 
const distFolder = join(process.cwd(), '../ng/dist/ng-universal-test/browser');
 
app(baseServer, distFolder).listen(4040, ()=>{
  console.log("Node Express server listening on http://localhost:4040");
});

If you try this out you should see your app come to life!

Conclusion

To recap, in order to import our Angular Universal app into our Express server we:

  • updated the app() function in server.ts

  • imported the app() function from the compiled main.js file

  • called the app() function passing in our existing Express app object and the path to our static files in our server's index.ts.

So there you have it. Now we can register our Angular Universal app in an existing Express app without needing to completely clone the world of dependencies from Angular into our combined server.

I hope this was able to clear things up a bit and help you avoid some headaches along the way.

Thanks for reading!

References