Client-Side Rendering in Angular

By Syncfusion®, Inc. · Last updated

How Angular renders in the browser: bootstrap, initial rendering, re-rendering and its triggers, the advantages and limitations of CSR, and a Product Catalog capstone project.

Introduction to Rendering

Rendering means creating the HTML that the user sees on the screen. In an Angular app, Angular renders your components: it turns each template and its data into HTML elements.

The important question is where and when the HTML is created. Angular supports three ways, called render modes:

Render modeWhere the HTML is createdWhen
Client-Side Rendering (CSR)In the user's browserWhen the user opens the page
Server-Side Rendering (SSR)On the serverFor each request
Static Site Generation (SSG)On your computer or build serverOnce, when you build the app

This lesson covers CSR. The next two lessons cover SSR and SSG.

Client-Side Rendering

In Client-Side Rendering (CSR), the server sends an almost empty HTML page and the JavaScript files of the app. The browser runs the JavaScript, and Angular creates all the HTML in the browser.

The client is the user's browser. CSR is the default: every app in this course so far used CSR.

Real-World Example

A company's sales dashboard is used only by employees after they log in. The browser downloads the Angular app once. After that, filtering orders, switching tabs and opening order details all happen in the browser, without new pages from the server.

Search engines never need to read the dashboard, so CSR is a good fit.

index.htmlempty <app-root>JavaScriptdownloadsAngularrenders in browserPage visiblecontent + dataThe user sees an empty page until Angular has rendered in the browser.

Client-Side Rendering: the browser creates the HTML.

CSR Architecture

Angular Application Bootstrap

Bootstrap means starting the app. The browser loads index.html, which contains only the root element:

<body>
  <app-root></app-root>
</body>

Then it runs main.ts, which starts Angular with the root component and the app configuration:

// main.ts
bootstrapApplication(App, appConfig);

Browser Rendering

Angular finds <app-root> in the page and renders the App component inside it. Then it renders every child component in the App template, one level after another.

All of this happens in the browser. The server only sent files. It did not create any of the visible HTML.

Root Component Initialization

When Angular creates a component, it follows a fixed order, which you learned in the Component Lifecycle Hook lesson:

  1. It runs the constructor. Services are injected here.
  2. It sets the @Input() values.
  3. It calls ngOnInit().
  4. It renders the template.

The next example writes a line at each step.

The order of initialization

import { Component, Injectable, Input, OnInit, inject, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StartupLog {
  readonly lines = signal<string[]>([]);
  add(line: string) {
    this.lines.update(list => [...list, line]);
  }
}

@Component({
  selector: 'app-header',
  template: `<p><b>{{ title }}</b></p>`,
})
export class Header implements OnInit {
  @Input() title = '';
  private log = inject(StartupLog);

  constructor() {
    this.log.add('3. Header: constructor');
  }

  ngOnInit() {
    this.log.add(`4. Header: ngOnInit, title = "${this.title}"`);
  }
}

@Component({
  selector: 'app-root',
  imports: [Header],
  template: `
    <app-header title="My Store" />
    @for (line of log.lines(); track $index) {
      <p>{{ line }}</p>
    }
  `,
})
export class App implements OnInit {
  log = inject(StartupLog);

  constructor() {
    this.log.add('1. App: constructor');
  }

  ngOnInit() {
    this.log.add('2. App: ngOnInit');
  }
}

App is initialized first. Then Angular renders its template and creates Header, with its title input already set in ngOnInit().

Initial Rendering

The initial rendering is the first time Angular creates the HTML of the page. In CSR, the user sees nothing useful until these steps are complete:

  1. The browser downloads index.html. The page is empty.
  2. The browser downloads the JavaScript files.
  3. Angular starts and renders the components.
  4. Components request their data from the server. Until it arrives, they show a loading text.
  5. The data arrives, and Angular renders it.

Initial rendering with data from a server

import { ApplicationConfig, Component, inject, signal } from '@angular/core';
import { HttpClient, HttpHandlerFn, HttpRequest, HttpResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { delay, of } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

@Component({
  selector: 'app-root',
  template: `
    <h4>Products</h4>
    @if (loading()) {
      <p>Loading...</p>
    } @else {
      @for (product of products(); track product.id) {
        <p>{{ product.name }}: {{ product.price }}</p>
      }
    }
  `,
})
export class App {
  products = signal<any[]>([]);
  loading = signal(true);

  constructor() {
    inject(HttpClient)
      .get<any[]>('/api/products')
      .subscribe(list => {
        this.products.set(list);
        this.loading.set(false);
      });
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers '/api/products' inside the browser, like in the API Integration lesson.
const PRODUCTS = [
  { id: 1, name: 'Wireless Mouse', category: 'Accessories', price: 15 },
  { id: 2, name: 'Mechanical Keyboard', category: 'Accessories', price: 40 },
  { id: 3, name: '27-inch Monitor', category: 'Displays', price: 180 },
  { id: 4, name: 'Portable Monitor', category: 'Displays', price: 120 },
  { id: 5, name: 'Headphones', category: 'Audio', price: 95 },
  { id: 6, name: 'Bluetooth Speaker', category: 'Audio', price: 35 },
];

function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
  return of(new HttpResponse({ status: 200, body: PRODUCTS })).pipe(delay(300));
}

Click Run and watch the output. "Loading..." appears first, and the list replaces it when the data arrives.

Until step 3 finishes, the user sees an empty page. On a slow phone or network, this can take several seconds. This is the main limitation of CSR, and the reason SSR and SSG exist.

Component Re-rendering

After the initial rendering, the page does not stay the same. Re-rendering means that Angular updates the HTML when data changes.

Angular does not create the whole page again. It checks the templates and changes only the elements whose values changed. For example, when a counter changes from 1 to 2, Angular changes only that number in the page.

Angular checks a component again when something tells it that data may have changed. These are the rendering triggers.

In the next example, each click changes one quantity. Angular updates only the number in that row. The other row and the heading are not created again.

Only the changed value is updated

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <h4>Your cart</h4>
    <p>Mouse × {{ mouse() }} <button (click)="mouse.update(n => n + 1)">+1</button></p>
    <p>Cable × {{ cable() }} <button (click)="cable.update(n => n + 1)">+1</button></p>
  `,
})
export class App {
  mouse = signal(1);
  cable = signal(2);
}

Open the browser's developer tools (F12), select the Elements tab and click a +1 button. Only the changed number flashes, because only this text was updated.

Rendering Triggers

User Events

User event(click), (input)State changesignal.set()Data updateHTTP answerChange detectionchecks templatesDOM updatedonly changed values

Three triggers start a re-render. Angular changes only what changed.

A user event in a template, such as (click) or (input), tells Angular to check the component after the event code runs.

State Changes

When a signal that a template reads gets a new value, Angular re-renders that template. This works for every change: after a click, a timer or a server answer.

Data Updates

When data arrives from a server, the code stores it. If it is stored in a signal, or shown with the async pipe, Angular re-renders the page with the new data.

Show a loading text while data is requested, and an error message if the request fails. Without them, the user sees an empty area and cannot know if the app is still working.

The next example has all three triggers.

Three rendering triggers

import { ApplicationConfig, Component, inject, signal } from '@angular/core';
import { HttpClient, HttpHandlerFn, HttpRequest, HttpResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { delay, of } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

@Component({
  selector: 'app-root',
  template: `
    <p>1. User event: clicked {{ clicks() }} times
      <button (click)="clicks.update(n => n + 1)">Click me</button></p>
    <p>2. State change: {{ seconds() }} seconds since start</p>
    <p>3. Data update: {{ productCount() }} products loaded</p>
  `,
})
export class App {
  clicks = signal(0);
  seconds = signal(0);
  productCount = signal(0);

  constructor() {
    setInterval(() => this.seconds.update(s => s + 1), 1000);
    inject(HttpClient)
      .get<any[]>('/api/products')
      .subscribe(list => this.productCount.set(list.length));
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers '/api/products' inside the browser, like in the API Integration lesson.
const PRODUCTS = [
  { id: 1, name: 'Wireless Mouse', category: 'Accessories', price: 15 },
  { id: 2, name: 'Mechanical Keyboard', category: 'Accessories', price: 40 },
  { id: 3, name: '27-inch Monitor', category: 'Displays', price: 180 },
  { id: 4, name: 'Portable Monitor', category: 'Displays', price: 120 },
  { id: 5, name: 'Headphones', category: 'Audio', price: 95 },
  { id: 6, name: 'Bluetooth Speaker', category: 'Audio', price: 35 },
];

function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
  return of(new HttpResponse({ status: 200, body: PRODUCTS })).pipe(delay(300));
}

A normal field changed by a timer or a server answer does not re-render the page. Store such values in signals, or show them with the async pipe.

Advantages and Limitations of CSR

Advantages:

  • Fast after the first load. Moving between screens needs no new pages from the server.
  • Simple hosting. The server only sends files, so any file hosting service works.
  • Low server cost. All rendering work happens in the users' browsers.
  • Very interactive. Good for dashboards, editors and tools that users work in for a long time.

Limitations:

  • Slow first view. The user sees an empty page until the JavaScript has downloaded and run.
  • Weaker for search engines. Some search engines and link previews (for example in chat apps) read only the first HTML, which is empty.
  • Depends on the user's device. A slow phone renders the app slowly.
Use CSR forConsider SSR or SSG for
Apps behind a login, such as dashboardsPublic pages that search engines must find
Tools that users keep openPages where the first view must be fast
Internal company appsBlogs, news, shops, documentation

Capstone Project: Product Catalog

Now you build a Product Catalog with CSR. It combines components, signals, state in a service and HttpClient.

Build Reusable Product Components

ProductCard displays one product. It receives the product with @Input() and reports the Add to cart click with @Output(). The same component is used for every product.

Implement Search Functionality

ProductStore stores the search text in a signal. The search box calls setSearch() on every key press.

Implement Category Filtering

The category buttons call setCategory(). A computed signal visibleProducts uses both the search text and the category.

Manage Application State

All state is in ProductStore: the products, the search text, the category and the cart. The writable signals are private, and components change them only with the store's methods.

Handle User Interactions

Typing, clicking a category and clicking Add to cart are user events. Each one calls a store method, and the signals re-render the page.

Display Dynamic Data

The products come from the server with HttpClient. The page shows "Loading..." until they arrive.

Capstone: Product Catalog

import { ApplicationConfig, Component, EventEmitter, Injectable, Input, Output, computed, inject, signal } from '@angular/core';
import { HttpClient, HttpHandlerFn, HttpRequest, HttpResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { delay, of } from 'rxjs';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([practiceServer]))],
};

export interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}

// ── State ──
@Injectable({ providedIn: 'root' })
export class ProductStore {
  private http = inject(HttpClient);
  private readonly _products = signal<Product[]>([]);
  private readonly _search = signal('');
  private readonly _category = signal('All');
  private readonly _cart = signal<Product[]>([]);
  readonly loading = signal(true);

  readonly category = this._category.asReadonly();
  readonly cartCount = computed(() => this._cart().length);
  readonly categories = computed(() => ['All', ...new Set(this._products().map(p => p.category))]);
  readonly visibleProducts = computed(() => {
    const text = this._search().toLowerCase();
    const category = this._category();
    return this._products().filter(
      p => (category === 'All' || p.category === category) && p.name.toLowerCase().includes(text),
    );
  });

  load() {
    this.http.get<Product[]>('/api/products').subscribe(list => {
      this._products.set(list);
      this.loading.set(false);
    });
  }
  setSearch(text: string) {
    this._search.set(text);
  }
  setCategory(category: string) {
    this._category.set(category);
  }
  addToCart(product: Product) {
    this._cart.update(list => [...list, product]);
  }
}

// ── Reusable component ──
@Component({
  selector: 'app-product-card',
  template: `
    <p>
      <b>{{ product.name }}</b> ({{ product.category }}) {{ product.price }}
      <button (click)="add.emit(product)">Add to cart</button>
    </p>
  `,
})
export class ProductCard {
  @Input() product!: Product;
  @Output() add = new EventEmitter<Product>();
}

// ── Page ──
@Component({
  selector: 'app-root',
  imports: [ProductCard],
  template: `
    <p>🛒 Cart: {{ store.cartCount() }}</p>
    <input placeholder="Search" (input)="store.setSearch($any($event.target).value)" />
    <p>
      @for (category of store.categories(); track category) {
        <button (click)="store.setCategory(category)" [disabled]="category === store.category()">{{ category }}</button>
      }
    </p>
    @if (store.loading()) {
      <p>Loading...</p>
    } @else {
      @for (product of store.visibleProducts(); track product.id) {
        <app-product-card [product]="product" (add)="store.addToCart($event)" />
      } @empty {
        <p>No products match your search.</p>
      }
    }
  `,
})
export class App {
  store = inject(ProductStore);
  constructor() {
    this.store.load();
  }
}
// ─── Practice server ──────────────────────────────────────────────
// Answers '/api/products' inside the browser, like in the API Integration lesson.
const PRODUCTS = [
  { id: 1, name: 'Wireless Mouse', category: 'Accessories', price: 15 },
  { id: 2, name: 'Mechanical Keyboard', category: 'Accessories', price: 40 },
  { id: 3, name: '27-inch Monitor', category: 'Displays', price: 180 },
  { id: 4, name: 'Portable Monitor', category: 'Displays', price: 120 },
  { id: 5, name: 'Headphones', category: 'Audio', price: 95 },
  { id: 6, name: 'Bluetooth Speaker', category: 'Audio', price: 35 },
];

function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
  return of(new HttpResponse({ status: 200, body: PRODUCTS })).pipe(delay(300));
}

Type "monitor" in the search box, click a category, and add products to the cart.

$any($event.target).value reads the text of the input. $any() tells TypeScript not to check the type of $event.target.

Download Project Files

Download CSR Project Source Code

Download angular-product-catalog-csr.zip

The ZIP file contains the Product Catalog as a complete Angular CLI project, with each component in its own file.

FileWhat it contains
src/app/product.model.tsThe Product interface
src/app/product-store.tsAll state: products, search, category and cart
src/app/components/product-card.tsThe reusable product card
src/app/components/search-box.tsThe search input
src/app/components/category-filter.tsThe category buttons
src/app/components/cart-summary.tsThe cart count and total
public/products.jsonThe product data that the app loads with HttpClient

Project Setup Instructions

You need Node.js 20.19 or newer. Check your version with node -v.

  1. Unzip the file and open a terminal in the product-catalog folder.
  2. Install the packages:

    npm install
  3. Start the app:

    npm start
  4. Open http://localhost:4200.

To see that this is CSR, open the page source in the browser (right-click → View Page Source). The HTML contains only <app-root></app-root>, without any products.

Common Mistakes

MistakeWhat you seeFix
A timer or server answer changes a normal fieldThe page does not updateStore the value in a signal
No loading text while data is requestedAn empty area, and users think the page is brokenShow a loading message until the data arrives
Using CSR for public pages that must appear in search enginesSearch previews are emptyUse SSR or SSG for those pages
Very large JavaScript filesA long empty page on slow phonesLazy-load pages that users rarely open

Check Yourself

Exercises

What to Remember

  • Rendering creates the HTML of the page. In CSR, the server sends an almost empty page and the JavaScript, and Angular renders everything in the browser.
  • Angular starts with bootstrapApplication(), renders the root component into <app-root>, and re-renders only what changed after user events, signal changes and data updates.
  • CSR is fast after the first load and simple to host, but the first view is slow and search engines may see an empty page.

Next: Server-Side Rendering, how the server creates the HTML so the first view is fast.