Dynamic Content with Angular Prerender

This is a part 2 of the series. And here is where most of the juice is.

In this part we will add dynamic content to the website. By dynamic content I mean any type of content that is not hardcoded in the HTML itself.

Specifically, here the idea is to have a set of markdown files, and use it as a source of content for the pages. In short here is what we will try to do:

  • have a markdown file for each page
  • when the prerender starts we want to take the markdown, turn it to HTML and generate prerendered pages
  • we want to have the page reachable on a clean url, like /post-number-one

Markdown to HTML

Let's start by creating the markdown file. That file can be anywhere, but let's have it in our repo, inside a content folder.

Now we need to get Angular Prerender to get that file, turn it into HTML and use it as the data in the prerendering.

When you setup Angular Universal, a file server.ts gets created. When starting this project I placed the endpoint to get the markdown content there. And I have spent hours trying to figure out why npm run prerender doesn't have access to that endpoint, and npm run dev:ssr does. Massive headache.

🕬 Prerender doesn't run the server.ts. Repeat again, prerender doesn't run the server.ts.

With this revelation I've set up a separate Node App that will serve as a micro-backend, serving only one endpoint.

Here is what we want the micro-backend to do:

  • expose one endpoint that will accept a url slug (ie: /post-number-one)
  • take the slug and search for the markdown file with that name
  • if the file exists it should transform the markdown to HTML
  • respond with the HTML content

For transforming the Markdown to HTML, I've used GitHub Octokit API.

Let's add it to the project.

npm i @octokit/core

We will create a file called backend.js, with the following content.

const express = require('express');
const { readFile, existsSync } = require('fs');
const Path = require('path');
const { Octokit } = require("@octokit/core");
const app = express();
const api = express.Router();
const octokit = new Octokit();

const markdownToHtml = (file) => {
    return new Promise((resolve, reject) => {
        readFile(file, 'utf8', function (err, data) {
            if (err) {
              return reject(err);
            }
            
            octokit.request('POST /markdown', {
              text: data
            }).then(d => {
              resolve(d.data);
            }).catch(e => reject(e));

          });
      })
}

api.get('/post/:slug', (req, res) => {
  const slug = req.params.slug;
  const filePath = `./content/${slug}.md`;
  const path = Path.join(__dirname, filePath);
  const fileExists = existsSync(path);
  
  if (!fileExists) {
    res.status(404).send('Page not found');
    return;
  }

  markdownToHtml(filePath)
  .then((d) => {
    res.json({
      content: d,
    });
  })
  .catch((e) => {
    res.status(400).send(e)
  });
});

app.use('/api', api);

const port = process.env.PORT || 8080;

app.listen(port, () => {
  console.log(
    `Backend is runing on: http://localhost:${port}`
  );
});

Add a script in package.json for running the micro-backend.

"backend:start": "node backend.js"

At this point we have a fully working micro-backend that we can run with npm run backend.

You can see this done in the following commit in the demo repository.

Setting up Angular to consume the micro-backend and present the content as a page

While developing the frontend you can start the micro-backend to run on your local machine, and you can use npm run dev:ssr to run Angular in the server side rendering mode. That way it will refresh on any change you do on the frontend side.

The steps explained here are visible in this commit in the demo repo.

We will need some modules added in the AppModule

  • HttpClientModule: we will use this to make HTTP request to our micro-backend
  • RouterModule: we will need to use ActivatedRoute to be able to get the URL parameter
  • BrowserTransferStateModule: that one works in conjuncture with ServerTransferStateModule

And for the AppServerModule add ServerTransferStateModule.

Let's add two components:

  • HomeComponent: we will use that for a static page for the homepage and the base route
  • PostComponent: we will use that to present the dynamic content fetched via HTTP during the pre-render process
ng g c post --module=app
ng g c home --module=app

When importing the RouterModule in the AppModule we will use this route setup:

RouterModule.forRoot([
  { path: '', component: HomeComponent },
  { path: '**', component: PostComponent }
]),

This wild card thing is a nifty way to tell Angular to send all routes we want to prerender to the PostComponent.

By default Angular Universal prerender will prerender the paths that are defined in the Router by using auto discover feature. As we won't have paths defined in the router, Angular allows us to define the exact paths we want to prerender.

To do that we will add routes.txt to the root of the project, with the following content.

/post-number-one

And we will change the prerender npm script to include the routes.txt definitions.

"prerender": "ng run angular-prerender-markdown:prerender --no-guess-routes --routes-file routes.txt"

The PostComponent

When you run the prerender, the prerender will search for a specified route in the routes.txt, it will then check the router to see what component to match with that route. In our case as we have ** pointing to the PostComponent, that means that every route (except for base route, which points to HomeComponent) is pointed to the PostComponent.

The PostComponent is pretty dumb. When initialized it uses the HttpService to get the content, and it renders that content, as-is, in the template.

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-post',
  templateUrl: './post.component.html',
  styleUrls: ['./post.component.css']
})
export class PostComponent implements OnInit {

  postContent: any = '';

  constructor(private route: ActivatedRoute,  private http: HttpClient) { }

  ngOnInit(): void {
    const slug = this.route.snapshot.url[0].path;
    this.http.get<{content: string}>(`http://localhost:8080/api/post/${slug}`).subscribe(data => {
      this.postContent = data.content;
    }, () => {
      this.postContent = 'Page not found';
    });
  }

}

And that is pretty much it. By doing this you can add markdown files to be pre-rendered by Angular Universal and serve them as a webpage.

This post you are reading is done in exactly that manner.

👉 Continue with part 3: Setup AWS S3 for Website Hosting