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 mode | Where the HTML is created | When |
|---|---|---|
| Client-Side Rendering (CSR) | In the user's browser | When the user opens the page |
| Server-Side Rendering (SSR) | On the server | For each request |
| Static Site Generation (SSG) | On your computer or build server | Once, 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:
- It runs the constructor. Services are injected here.
- It sets the
@Input()values. - It calls
ngOnInit(). - 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:
- The browser downloads
index.html. The page is empty. - The browser downloads the JavaScript files.
- Angular starts and renders the components.
- Components request their data from the server. Until it arrives, they show a loading text.
- 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 for | Consider SSR or SSG for |
|---|---|
| Apps behind a login, such as dashboards | Public pages that search engines must find |
| Tools that users keep open | Pages where the first view must be fast |
| Internal company apps | Blogs, 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.
| File | What it contains |
|---|---|
src/app/product.model.ts | The Product interface |
src/app/product-store.ts | All state: products, search, category and cart |
src/app/components/product-card.ts | The reusable product card |
src/app/components/search-box.ts | The search input |
src/app/components/category-filter.ts | The category buttons |
src/app/components/cart-summary.ts | The cart count and total |
public/products.json | The 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.
- Unzip the file and open a terminal in the
product-catalogfolder. Install the packages:
npm installStart the app:
npm start- 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
| Mistake | What you see | Fix |
|---|---|---|
| A timer or server answer changes a normal field | The page does not update | Store the value in a signal |
| No loading text while data is requested | An empty area, and users think the page is broken | Show a loading message until the data arrives |
| Using CSR for public pages that must appear in search engines | Search previews are empty | Use SSR or SSG for those pages |
| Very large JavaScript files | A long empty page on slow phones | Lazy-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.