Placeholder image

Cody Potter - Posted on March 24, 2024

Upgrading your Angular app to use Server Side Rendering

Last time, we talked about some big limitations with traditional client-side rendered applications with regard to search engine optimization. Today we're going to take a look at how to fix the problem with Angular server-side rendering -- SSR using @angular/ssr. Finally, we're going to deploy it to Firebase. If you are starting from scratch, the angular cli allows you to start with an SSR application, so if you know at the project outset that you will absolutely need SSR, please choose that option.

Start with a client-side rendered application

If you're like me, you already have a traditional CSR app, and you need to add SSR to it. So first, lets make a minimal CSR application, just so we're all on the same page.

cody@Cody-15:~/personal$ npm init @angular csr2ssr
Need to install the following packages:
@angular/create@17.3.1
Ok to proceed? (y) y
? Which stylesheet format would you like to use? Sass (SCSS)     [ https://sass-lang.com/documentation/syntax#scss ]
? Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? No
CREATE csr2ssr/README.md (1061 bytes)
CREATE csr2ssr/.editorconfig (274 bytes)
CREATE csr2ssr/.gitignore (548 bytes)
CREATE csr2ssr/angular.json (2815 bytes)
CREATE csr2ssr/package.json (1038 bytes)
CREATE csr2ssr/tsconfig.json (857 bytes)
CREATE csr2ssr/tsconfig.app.json (263 bytes)
CREATE csr2ssr/tsconfig.spec.json (273 bytes)
CREATE csr2ssr/.vscode/extensions.json (130 bytes)
CREATE csr2ssr/.vscode/launch.json (470 bytes)
CREATE csr2ssr/.vscode/tasks.json (938 bytes)
CREATE csr2ssr/src/main.ts (250 bytes)
CREATE csr2ssr/src/favicon.ico (15086 bytes)
CREATE csr2ssr/src/index.html (293 bytes)
CREATE csr2ssr/src/styles.scss (80 bytes)
CREATE csr2ssr/src/app/app.component.scss (0 bytes)
CREATE csr2ssr/src/app/app.component.html (19903 bytes)
CREATE csr2ssr/src/app/app.component.spec.ts (919 bytes)
CREATE csr2ssr/src/app/app.component.ts (304 bytes)
CREATE csr2ssr/src/app/app.config.ts (227 bytes)
CREATE csr2ssr/src/app/app.routes.ts (77 bytes)
CREATE csr2ssr/src/assets/.gitkeep (0 bytes)
✔ Packages installed successfully.

Nice! We made a traditional angular application. Lets test it out

cody@Cody-15:~/personal$ cd csr2ssr/
cody@Cody-15:~/personal/csr2ssr$ npm start

I also removed all the angular placeholder stuff in the app.component.html file. Once the app is up and running, open it in the browser, right click the page and select "View as Source" content image Notice the <app-root> here. The angular app has not yet been bootstrapped in the initial index.html. Also note the <title> tag in the head, It is set to your app name, this is the hard-coded value in your index.html file. It is not dynamic.

Placeholder Service

Let's make a service using json placeholder just so we can fetch some random data, similar to how your real-life application might asynchronously load some data from a web server.

cody@Cody-15:~/personal/csr2ssr$ ng g s placeholder
CREATE src/app/placeholder.service.spec.ts (382 bytes)
CREATE src/app/placeholder.service.ts (140 bytes)
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

@Injectable({
  providedIn: 'root'
})
export class PlaceholderService {

  constructor(private http: HttpClient) { }

  getPost(id: number) {
    return this.http.get<Post>(`https://jsonplaceholder.typicode.com/posts/${id}`);
  }
}

Make a component

Now let's make a component to render the post.

cody@Cody-15:~/personal/csr2ssr$ ng g c post --standalone
CREATE src/app/post/post.component.scss (0 bytes)
CREATE src/app/post/post.component.html (19 bytes)
CREATE src/app/post/post.component.spec.ts (582 bytes)
CREATE src/app/post/post.component.ts (227 bytes)

This component will pull the id off the route params, and then fetch the post using the placeholder service. Once we have the post, we'll use its values to set the html head's title and a few meta tags.

import { Component, OnInit } from '@angular/core';
import { PlaceholderService, Post } from '../placeholder.service';
import { ActivatedRoute } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';
import { switchMap } from 'rxjs';

@Component({
  selector: 'app-post',
  standalone: true,
  imports: [],
  template: `
    <h1>{{post.title}}</h1>
    <p>{{post.body}}</p>
  `,
  styleUrl: './post.component.scss'
})
export class PostComponent implements OnInit {
  post: Post = {
    userId: 0,
    id: 0,
    title: '',
    body: '',
  };

  constructor(
    private placeholder: PlaceholderService,
    private route: ActivatedRoute,
    private meta: Meta,
    private title: Title,
  ) {}

  ngOnInit(): void {
    this.route.params.pipe(
      switchMap(params => this.placeholder.getPost(params['id']))
    ).subscribe(post => {
      this.post = post;
      this.title.setTitle(post.title);
      this.meta.addTags([
        { name: 'description', content: post.body},
        { name: 'author', content: post.userId.toString()},
      ]);
    });
  }
}

Set up route

Awesome, we have our service and our component. Now we have to render it when we go to the /posts/:id page. Lets add that to app.routes.ts.

import { Routes } from '@angular/router';
import { PostComponent } from './post/post.component';

export const routes: Routes = [
    {
        path: 'posts/:id', component: PostComponent,
    }
];

Don't forget to provide the HttpClientModule and your PlaceholderService in your app.config.ts (like I did).

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { PlaceholderService } from './placeholder.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    PlaceholderService,
  ]
};

Test out our CSR + Component

We're ready to test out our little app.

cody@Cody-15:~/personal/csr2ssr$ npm start

Once your dev server is up and running, open the browser and navigate to http://localhost:4200/posts/1 . You should see your PostComponent rendered.

content image When you open the console and look at the DOM tree, you'll notice that the head is populated with your post's data. Awesome it worked! Or did it?

Right click the page and select "View as Source".

content image Weird? The meta tags aren't there anymore. They're set to the default values. What is going on here?

Hint: View as Source shows you the plain HTML without executing JavaScript.

Here's a sequence diagram to see the root cause. content image See what's going on? The Meta tag setters aren't executed until JavaScript is executed on the page, your app is bootstrapped, your client side routing kicks in, and the PostComponent is rendered.

"View Page as Source" shows you how a web crawler sees your page.

We need Server-Side Rendering to solve this problem.

Why does SSR solve the problem?

SSR means that your web server builds the base web page before it is sent to the client. This means that the base HTML will have all your meta tags and everything set before it even gets to the browser.

Here is what it looks like using SSR: content image

Hello SSR

Let's set up SSR in our project. It's surprisingly easy and we only need to change a couple things.

cody@Cody-15:~/personal/csr2ssr$ ng add @angular/ssr
ℹ Using package manager: npm
✔ Found compatible package version: @angular/ssr@17.3.1.
✔ Package information loaded.

The package @angular/ssr@17.3.1 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
CREATE src/main.server.ts (264 bytes)
CREATE src/app/app.config.server.ts (350 bytes)
CREATE server.ts (1703 bytes)
UPDATE angular.json (2961 bytes)
UPDATE tsconfig.app.json (324 bytes)
UPDATE package.json (1266 bytes)
UPDATE src/app/app.config.ts (413 bytes)

If you tried to do npm start right now, you would notice that its a bit broken, and your app is still not bootstrapped server-side.

Double check your app config

Your app config should now have a provideClientHydration() provider. If it doesn't, add it.

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideClientHydration } from '@angular/platform-browser';
import { PlaceholderService } from './placeholder.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    PlaceholderService,
    provideClientHydration(),
  ]
};

Note: You may also need to add provideHttpClient(withFetch()) to your app.config.server.ts NOT app.config.ts. Your mileage may vary. In testing I found that this example works without it, but the documentation suggests that it is required for server-side fetching. I have a hunch that this is mostly for the use-case of adding an express handler to your server that uses fetch as a proxy, but the documentation could be a little clearer here.

Route Resolver

To make sure that our http request happens on the server-side, we need a route resolver.

cody@Cody-15:~/personal/csr2ssr$ ng g resolver post
CREATE src/app/post.resolver.spec.ts (486 bytes)
CREATE src/app/post.resolver.ts (132 bytes)
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { PlaceholderService, Post } from './placeholder.service';

export const postResolver: ResolveFn<Post> = (route) => inject(PlaceholderService)
  .getPost(route.params['id']);

Once we have our resolver, lets add it to our app.routes.ts. This will allow angular to fetch the post during route resolution before it renders the PostComponent.

import { Routes } from '@angular/router';
import { PostComponent } from './post/post.component';
import { postResolver } from './post.resolver';

export const routes: Routes = [
    {
        path: 'posts/:id', component: PostComponent,
        resolve: { post: postResolver },
    }
];

Modification to our PostComponent

Now that we're using a resolver, lets update our PostComponent to actually make use of it.

import { Component, OnInit } from '@angular/core';
import { Post } from '../placeholder.service';
import { ActivatedRoute } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';

@Component({
  selector: 'app-post',
  standalone: true,
  imports: [],
  template: `
    <h1>{{post.title}}</h1>
    <p>{{post.body}}</p>
  `,
  styleUrl: './post.component.scss'
})
export class PostComponent implements OnInit {
  post: Post = {
    userId: 0,
    id: 0,
    title: '',
    body: '',
  };

  constructor(
    private route: ActivatedRoute,
    private meta: Meta,
    private title: Title,
  ) {}

  ngOnInit(): void {
    this.route.data.subscribe(data => {
      this.post = data['post'];
      this.title.setTitle(this.post.title);
      this.meta.addTags([
        { name: 'description', content: this.post.body},
        { name: 'author', content: this.post.userId.toString()},
      ]);
    });
  }
}

Test it out

Ok we should be all set here, lets test it out.

cody@Cody-15:~/personal/csr2ssr$ npm start

Lets open up the browser to http://localhost:4200/posts/1 and "View as Source"

content image Nice, we can see that our page source looks how we expect it to look. We now see all our meta tags and we can even see the full structure of the page, not just an <app-root> element.

Deployment

I have found plenty of resources online for setting up SSR locally, but not a ton of resources for deploying it. Luckily, Firebase makes it extremely easy to set up.

cody@Cody-15:~/personal/csr2ssr$ ng add @angular/fire
ℹ Using package manager: npm
✔ Found compatible package version: @angular/fire@17.0.1.
✔ Package information loaded.

The package @angular/fire@17.0.1 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
UPDATE package.json (1298 bytes)
✔ Packages installed successfully.
? What features would you like to setup? ng deploy -- hosting
Using firebase-tools version 13.5.2
? Which Firebase account would you like to use? me@codypotter.com
✔ Preparing the list of your Firebase projects
? Please select a project: [CREATE NEW PROJECT]
? Please specify a unique project id (cannot be modified afterward) [6-30 characters]: csr2ssr
? What would you like to call your project? csr2ssr
✔ Creating Google Cloud Platform project
✔ Adding Firebase resources to Google Cloud Platform project
? In which region would you like to host server-side content? us-east1 (South Carolina)
? Please select a hosting site: https://csr2ssr.web.app
CREATE .firebaserc (169 bytes)
UPDATE .gitignore (602 bytes)
UPDATE angular.json (3501 bytes)
UPDATE firebase.json (148 bytes)

For this next part, your project MUST be using the "blaze" plan. Or the "pay-as-you-go" plan as they explain it. Feel free to set up alerts and all that to avoid accidental charges. As of this writing, the Blaze plan includes all of the free headroom of the free, spark, plan as well as $300 in credit.

I must admit, this is one big downside to SSR deployments, they tend to cost money. If you find a way to do this for free and it's not a total headache, please leave a comment below! Especially if its in AWS, just please no Elastic Beanstalk 🙃.

Finally:

cody@Cody-15:~/personal/csr2ssr$ ng deploy
... this part takes a while but eventually you get a success message and link to your deployed application.

Congratulations! You have just deployed a full stack Angular application! Try plugging your deployed link into this open graph previewer to see the fruits of your labor!