Angular Hybrid Rendering
Introduction
Angular Hybrid Rendering is a powerful approach that combines the best of both server-side rendering (SSR) and client-side rendering (CSR) to optimize your Angular applications. In this guide, we'll explore how hybrid rendering works, why it's beneficial, and how to implement it in your Angular projects.
Hybrid rendering allows you to:
- Generate the initial page content on the server for faster initial load times
- Improve SEO by serving complete HTML to search engines
- Provide interactive content once the JavaScript bundles load and hydrate the application
This balance between server and client rendering offers the best compromise between performance, SEO, and interactivity for most modern web applications.
Understanding Hybrid Rendering
Hybrid rendering in Angular involves a multi-step process:
- Server-Side Rendering (SSR): The initial page is rendered on the server
- HTML Transfer: The server sends complete HTML to the client
- Hydration: The client-side Angular application "hydrates" the server-rendered HTML
- Client-Side Rendering (CSR): Subsequent navigation and interactions are handled client-side
Let's explore each step in detail.
Server-Side Rendering
When a user requests a page, the server runs Angular to generate the initial HTML:
// server.ts
import 'zone.js/node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { AppServerModule } from './src/main.server';
const app = express();
app.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
app.set('view engine', 'html');
app.set('views', './dist/app-name/browser');
app.get('*', (req, res) => {
res.render('index', { req });
});
app.listen(4000, () => {
console.log('Angular SSR server running on http://localhost:4000');
});
HTML Transfer and Initial View
The server sends complete HTML to the browser, allowing users to see content immediately:
<!-- Example of server-rendered HTML output -->
<html>
<head>
<title>My Angular App</title>
</head>
<body>
<app-root>
<header>
<h1>Welcome to My App</h1>
<nav>...</nav>
</header>
<main>
<article>
<h2>Featured Content</h2>
<p>This content was rendered on the server!</p>
</article>
</main>
<footer>...</footer>
</app-root>
<!-- Client-side JavaScript bundles will be included here -->
<script src="runtime.js"></script>
<script src="polyfills.js"></script>
<script src="main.js"></script>
</body>
</html>
Hydration Process
Once the JavaScript bundles load in the browser, Angular "hydrates" the existing DOM instead of re-rendering it:
// main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err));
});
Setting Up Hybrid Rendering in Angular
Let's implement hybrid rendering in an Angular application:
Step 1: Install Angular Universal
ng add @nguniversal/express-engine
This command adds the required dependencies and configures your Angular application for server-side rendering.
Step 2: Configure Your Application for Hybrid Rendering
Update your app.module.ts
to support hybrid rendering:
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule.withServerTransition({ appId: 'my-app' }),
BrowserTransferStateModule
],
bootstrap: [AppComponent]
})
export class AppModule { }
And create a server module:
// app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule
],
bootstrap: [AppComponent],
})
export class AppServerModule {}
Step 3: Transfer State Between Server and Client
To avoid duplicate data fetching, implement transfer state:
// product.service.ts
import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { isPlatformServer, isPlatformBrowser } from '@angular/common';
import { TransferState, makeStateKey, StateKey } from '@angular/platform-browser';
import { Observable, of } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
const PRODUCTS_KEY = makeStateKey<any[]>('products');
@Injectable({
providedIn: 'root'
})
export class ProductService {
constructor(
private http: HttpClient,
private transferState: TransferState,
@Inject(PLATFORM_ID) private platformId: Object
) { }
getProducts(): Observable<any[]> {
// Check if we have products in the transfer state
if (this.transferState.hasKey(PRODUCTS_KEY)) {
const products = this.transferState.get(PRODUCTS_KEY, []);
// Remove after use to avoid keeping large data in memory
this.transferState.remove(PRODUCTS_KEY);
return of(products);
}
// Otherwise fetch from API
return this.http.get<any[]>('https://api.example.com/products').pipe(
tap(products => {
// If on server, store the result in transfer state
if (isPlatformServer(this.platformId)) {
this.transferState.set(PRODUCTS_KEY, products);
}
}),
catchError(error => {
console.error('Error fetching products', error);
return of([]);
})
);
}
}
Step 4: Handle Browser-Specific Code
Some code should only run in the browser or server:
// app.component.ts
import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-root',
template: `
<h1>Product Catalog</h1>
<div *ngFor="let product of products">
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p>{{ product.price | currency }}</p>
</div>
`
})
export class AppComponent implements OnInit {
products: any[] = [];
constructor(
private productService: ProductService,
@Inject(PLATFORM_ID) private platformId: Object
) {}
ngOnInit(): void {
// Fetch products using our service with transfer state
this.productService.getProducts().subscribe(
products => this.products = products
);
if (isPlatformBrowser(this.platformId)) {
// Browser-only code
this.initBrowserFeatures();
}
}
private initBrowserFeatures(): void {
// Initialize features that only work in the browser
// For example: local storage, window events, etc.
console.log('Running in browser - initializing browser features');
}
}
Real-World Example: E-commerce Product Page
Let's see how hybrid rendering benefits an e-commerce product page:
// product-page.component.ts
import { Component, OnInit, Inject, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ProductService } from './product.service';
import { Meta, Title } from '@angular/platform-browser';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-product-page',
template: `
<div *ngIf="product" class="product-container">
<div class="product-gallery">
<img [src]="product.imageUrl" [alt]="product.name">
</div>
<div class="product-info">
<h1>{{ product.name }}</h1>
<div class="price">{{ product.price | currency }}</div>
<p class="description">{{ product.description }}</p>
<div class="actions">
<button (click)="addToCart()" class="add-to-cart">Add to Cart</button>
<button (click)="buyNow()" class="buy-now">Buy Now</button>
</div>
</div>
</div>
`,
styles: [`
.product-container {
display: flex;
padding: 2rem;
gap: 2rem;
}
.product-gallery {
flex: 1;
}
.product-info {
flex: 1;
}
`]
})
export class ProductPageComponent implements OnInit {
product: any;
constructor(
private route: ActivatedRoute,
private productService: ProductService,
private meta: Meta,
private title: Title,
@Inject(PLATFORM_ID) private platformId: Object
) {}
ngOnInit(): void {
const productId = this.route.snapshot.paramMap.get('id');
this.productService.getProductById(productId).subscribe(product => {
this.product = product;
// Set SEO metadata
this.title.setTitle(`${product.name} | My Store`);
this.meta.updateTag({ name: 'description', content: product.description.substring(0, 160) });
this.meta.updateTag({ property: 'og:title', content: product.name });
this.meta.updateTag({ property: 'og:description', content: product.description.substring(0, 160) });
this.meta.updateTag({ property: 'og:image', content: product.imageUrl });
});
}
addToCart(): void {
if (isPlatformBrowser(this.platformId)) {
// Only execute cart functionality in browser
console.log('Adding to cart:', this.product);
// Implement cart logic here
}
}
buyNow(): void {
if (isPlatformBrowser(this.platformId)) {
// Only execute in browser
console.log('Buying now:', this.product);
// Implement checkout logic here
}
}
}
Benefits in this example:
- SEO optimized: The product page is fully rendered on the server with complete metadata
- Fast initial load: Users see the product immediately without waiting for JavaScript
- Interactivity preserved: "Add to Cart" and "Buy Now" buttons work after hydration
- Transfer state: Product data is fetched once on the server and passed to the client
Performance Optimization Tips
To get the most out of hybrid rendering:
-
Minimize initial page size:
typescript// Use lazy loading for routes
const routes: Routes = [
{
path: 'products',
loadChildren: () => import('./products/products.module')
.then(m => m.ProductsModule)
}
]; -
Defer non-critical content:
html<div *ngIf="isDataLoaded">
<!-- Heavy content here -->
</div> -
Prioritize critical CSS:
typescript// In your Angular component
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
encapsulation: ViewEncapsulation.None
}) -
Use preloading strategies:
typescript@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
})
]
})
Common Challenges and Solutions
Challenge 1: Window is not defined
// Wrong approach - will fail during SSR
ngOnInit() {
const windowWidth = window.innerWidth; // Error during SSR
}
// Correct approach
import { PLATFORM_ID, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
const windowWidth = window.innerWidth; // Safe
}
}
Challenge 2: Third-party libraries requiring DOM
// app.module.ts - Use APP_INITIALIZER for browser-only libraries
import { APP_INITIALIZER, NgModule } from '@angular/core';
export function initializeApp(platformId: Object) {
return () => {
if (isPlatformBrowser(platformId)) {
// Initialize third-party libraries that require DOM
}
return Promise.resolve();
};
}
@NgModule({
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [PLATFORM_ID],
multi: true
}
]
})
export class AppModule { }
Summary
Angular Hybrid Rendering combines the best of server-side and client-side rendering to create fast, SEO-friendly, and interactive web applications. By rendering the initial content on the server and hydrating it on the client, you can provide users with an optimal experience.
Key benefits include:
- Faster initial page load
- Better SEO performance
- Improved user experience
- Reduced content flickering
- Better performance on low-powered devices
As you build Angular applications, consider implementing hybrid rendering for pages where SEO and initial load speed are critical, such as product pages, landing pages, and content-heavy sections.
Additional Resources
- Angular Universal Documentation
- TransferState API Documentation
- Angular Performance Optimization Guide
Exercises
- Convert an existing Angular CSR application to use hybrid rendering.
- Implement transfer state for an API call to prevent duplicate data fetching.
- Create a product listing page that uses SSR for the initial list and CSR for filtering and pagination.
- Optimize a hybrid rendered application by adding lazy loading for non-critical components.
- Implement SEO metadata that changes dynamically based on content loaded from an API.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)