Mình từng build một e-commerce site bằng Angular. Sản phẩm đẹp, UX mượt, checkout nhanh. Một tháng sau launch, marketing hỏi "sao Google không index trang sản phẩm nào cả?". Mở Google Search Console — Googlebot crawl trang, thấy <app-root></app-root>, không thấy content. Hàng nghìn trang sản phẩm, zero indexed.
Đó là thực tế của SPA thuần: browser tải JavaScript, chạy Angular, render HTML. Nhưng Googlebot mặc dù có thể chạy JavaScript, nó không luôn chờ đủ lâu, không luôn render đúng, và definitely không index nhanh bằng static HTML. Social media crawlers (Facebook, Twitter) thì hoàn toàn không chạy JavaScript — share link chỉ thấy title "My App" với thumbnail rỗng.
Angular SSR giải quyết vấn đề này: server render HTML đầy đủ trước khi gửi cho browser. Googlebot nhận HTML có sẵn content — index ngay. Facebook crawler nhận meta tags — hiện preview đẹp. User nhận trang đã render — thấy content ngay thay vì loading spinner.
Bài viết này setup SSR từ đầu cho Angular 17+, xử lý từng vấn đề SEO cụ thể, và chỉ ra những cái bẫy mình đã dẫm phải.
Tại sao SPA có vấn đề với SEO
Khi Googlebot crawl Angular SPA thuần, nó nhận được:
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<app-root></app-root>
<script src="main.js"></script>
</body>
</html>
Rỗng. Không có product name, không có description, không có price, không có hình. Googlebot phải chạy main.js rồi chờ Angular bootstrap, fetch API, render component. Quá trình này gọi là "rendering budget" — Google có giới hạn resource dành cho mỗi page. Trang nào nặng, chạy lâu, API chậm → Googlebot bỏ qua, đánh index với HTML rỗng.
Với SSR, server chạy Angular trước, render HTML hoàn chỉnh:
<!DOCTYPE html>
<html>
<head>
<title>iPhone 15 Pro Max | MyShop</title>
<meta name="description" content="iPhone 15 Pro Max 256GB. Giá 29.990.000đ. Giao hàng miễn phí." />
<meta property="og:image" content="https://myshop.com/images/iphone15.jpg" />
</head>
<body>
<app-root>
<h1>iPhone 15 Pro Max</h1>
<p class="price">29.990.000đ</p>
<img src="/images/iphone15.jpg" alt="iPhone 15 Pro Max" />
<!-- toàn bộ HTML đã render -->
</app-root>
<script src="main.js"></script>
</body>
</html>
Googlebot thấy ngay content, index ngay. Facebook thấy og:image, hiện preview đẹp. User thấy trang render xong trước khi JavaScript load — First Contentful Paint nhanh hơn đáng kể.
Setup SSR cho Angular 17+
Thêm SSR vào project có sẵn
ng add @angular/ssr
Một lệnh. Angular CLI tự:
- Cài
@angular/ssrpackage - Tạo
server.ts— Express server cho SSR - Cập nhật
angular.json— thêm build config cho server - Cập nhật
app.config.ts— thêmprovideClientHydration() - Tạo
app.config.server.ts— server-side providers
Project mới với SSR từ đầu
ng new my-app --ssr
Flag --ssr tạo project đã setup SSR sẵn.
Kiểm tra file được tạo
// app.config.ts — đã thêm hydration
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(), // ← quan trọng
provideHttpClient(withFetch()),
],
};
// app.config.server.ts — server providers
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
// server.ts — Express server
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
const app = express();
const commonEngine = new CommonEngine();
app.get('*', (req, res, next) => {
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: req.originalUrl,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
Build và chạy
# build cả client và server
ng build
# chạy server
node dist/my-app/server/server.mjs
# hoặc dev mode
ng serve --ssr
Mở browser → View Page Source → thấy full HTML rendered. Đó là SSR đang hoạt động.
Client Hydration — phép thuật đằng sau
Angular 17+ dùng non-destructive hydration. Nghĩa là:
- Server render HTML đầy đủ → gửi cho browser
- Browser hiển thị HTML ngay (user thấy content)
- Angular JavaScript load, bootstrap
- Angular "hydrate" — gắn event listeners vào DOM đã có, KHÔNG render lại từ đầu
Trước Angular 16, SSR render HTML, rồi client Angular destroy toàn bộ DOM rồi render lại — gây flicker. Hydration khắc phục: Angular nhận diện DOM nodes server đã tạo, attach logic vào, không DOM manipulation.
// provideClientHydration() bật hydration
// không có nó, Angular sẽ re-render DOM → flicker
providers: [
provideClientHydration(),
]
Hydration mismatch — lỗi hay gặp nhất
Nếu server render khác client render → hydration fail → Angular fallback re-render toàn bộ DOM:
// ❌ gây hydration mismatch — server không có window/document
@Component({
template: `<p>Width: {{ screenWidth }}</p>`,
})
export class MyComponent {
screenWidth = window.innerWidth; // ERROR trên server
}
Server không có window, document, localStorage. Code reference browser API trực tiếp sẽ crash hoặc trả giá trị khác → mismatch.
Fix: check platform trước khi dùng browser API:
import { isPlatformBrowser, PLATFORM_ID } from '@angular/common';
@Component({
template: `<p>Width: {{ screenWidth }}</p>`,
})
export class MyComponent {
private platformId = inject(PLATFORM_ID);
screenWidth = 0;
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
this.screenWidth = window.innerWidth;
}
}
}
Hoặc dùng afterNextRender — hook chỉ chạy trên browser:
export class ChartComponent {
constructor() {
afterNextRender(() => {
// code này CHỈ chạy trên browser, SAU hydration
this.initChart();
window.addEventListener('resize', this.onResize);
});
}
}
afterNextRender là best practice từ Angular 17 — thay isPlatformBrowser check trong ngOnInit. Gọn hơn, ý đồ rõ hơn.
Những thứ hay gây mismatch
// ❌ Date/time khác giữa server và client timezone
template: `{{ now | date:'HH:mm' }}`
// server ở UTC: "14:00", client ở UTC+7: "21:00" → mismatch
// ✅ fix: render trên client only
@if (isBrowser) {
<span>{{ now | date:'HH:mm' }}</span>
}
// ❌ Math.random() — giá trị khác mỗi lần render
template: `<div [id]="'comp-' + randomId">`
// ❌ localStorage — server không có
ngOnInit() {
this.theme = localStorage.getItem('theme'); // null trên server
}
Rule: bất kỳ thứ gì non-deterministic (random, time, browser state) hoặc browser-only (DOM API, localStorage, window) đều cần guard.
SSR render HTML nhưng meta tags vẫn cần dynamic — mỗi trang sản phẩm cần title, description, og:image khác nhau.
Meta Service
// seo.service.ts
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { DOCUMENT } from '@angular/common';
export interface SeoData {
title: string;
description: string;
image?: string;
url?: string;
type?: 'website' | 'article' | 'product';
price?: string;
currency?: string;
}
@Injectable({ providedIn: 'root' })
export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
private doc = inject(DOCUMENT);
update(data: SeoData) {
const siteName = 'MyShop';
const fullTitle = `${data.title} | ${siteName}`;
// basic
this.title.setTitle(fullTitle);
this.meta.updateTag({ name: 'description', content: data.description });
// Open Graph (Facebook, LinkedIn)
this.meta.updateTag({ property: 'og:title', content: fullTitle });
this.meta.updateTag({ property: 'og:description', content: data.description });
this.meta.updateTag({ property: 'og:type', content: data.type ?? 'website' });
if (data.image) this.meta.updateTag({ property: 'og:image', content: data.image });
if (data.url) this.meta.updateTag({ property: 'og:url', content: data.url });
// Twitter Card
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: fullTitle });
this.meta.updateTag({ name: 'twitter:description', content: data.description });
if (data.image) this.meta.updateTag({ name: 'twitter:image', content: data.image });
// canonical URL
this.updateCanonical(data.url);
}
private updateCanonical(url?: string) {
if (!url) return;
let link = this.doc.querySelector('link[rel="canonical"]') as HTMLLinkElement;
if (!link) {
link = this.doc.createElement('link');
link.setAttribute('rel', 'canonical');
this.doc.head.appendChild(link);
}
link.setAttribute('href', url);
}
// JSON-LD structured data
setStructuredData(data: object) {
let script = this.doc.querySelector('script[type="application/ld+json"]') as HTMLScriptElement;
if (!script) {
script = this.doc.createElement('script');
script.type = 'application/ld+json';
this.doc.head.appendChild(script);
}
script.text = JSON.stringify(data);
}
}
Dùng trong component
// product-detail.component.ts
@Component({
template: `
@if (product(); as p) {
<h1>{{ p.name }}</h1>
<p class="price">{{ p.price | currency:'VND' }}</p>
<img [src]="p.imageUrl" [alt]="p.name" />
<div [innerHTML]="p.description"></div>
}
`,
})
export class ProductDetailComponent {
private route = inject(ActivatedRoute);
private productService = inject(ProductService);
private seo = inject(SeoService);
product = toSignal(
this.route.paramMap.pipe(
switchMap(params => this.productService.getById(params.get('id')!)),
tap(product => this.updateSeo(product))
)
);
private updateSeo(product: Product) {
this.seo.update({
title: product.name,
description: `${product.name}. Giá: ${product.price.toLocaleString('vi-VN')}đ. ${product.shortDescription}`,
image: product.imageUrl,
url: `https://myshop.com/products/${product.slug}`,
type: 'product',
});
// structured data cho Google Rich Results
this.seo.setStructuredData({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.imageUrl,
description: product.shortDescription,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'VND',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
});
}
}
JSON-LD structured data cho Google hiểu đây là sản phẩm, có giá, có tình trạng kho. Google hiển thị rich snippet — star rating, price, availability ngay trên search result.
Route-level SEO với resolver
Cho trang tĩnh (About, Contact, FAQ), set meta trong route config thay vì component:
// app.routes.ts
export const routes: Routes = [
{
path: '',
component: HomeComponent,
data: {
seo: {
title: 'Trang chủ',
description: 'MyShop — Mua sắm online uy tín, giao hàng nhanh toàn quốc.',
}
}
},
{
path: 'about',
component: AboutComponent,
data: {
seo: {
title: 'Về chúng tôi',
description: 'Câu chuyện MyShop — từ startup nhỏ đến thương hiệu tin cậy.',
}
}
},
{
path: 'products/:slug',
component: ProductDetailComponent,
// SEO set dynamic trong component — không dùng route data
},
];
// seo-route.guard.ts — tự update SEO từ route data
export const seoGuard = () => {
const route = inject(ActivatedRoute);
const seo = inject(SeoService);
const router = inject(Router);
router.events.pipe(
filter(e => e instanceof NavigationEnd)
).subscribe(() => {
const data = route.snapshot.firstChild?.data?.['seo'];
if (data) seo.update(data);
});
};
Static Prerender — best performance, best SEO
SSR render mỗi request — server chạy Angular mỗi lần user truy cập. Với trang không đổi thường xuyên (sản phẩm, blog), render trước lúc build nhanh hơn nhiều.
Config prerender
// angular.json
{
"projects": {
"my-app": {
"architect": {
"build": {
"configurations": {
"production": {
"prerender": {
"routesFile": "routes.txt"
}
}
}
}
}
}
}
}
# routes.txt — danh sách URL cần prerender
/
/about
/contact
/products/iphone-15
/products/samsung-s24
/products/macbook-pro
/blog/angular-ssr-guide
ng build
# output: dist/my-app/browser/
# index.html ← homepage prerendered
# about/index.html ← about page prerendered
# products/iphone-15/index.html ← product prerendered
Mỗi route thành file HTML tĩnh — serve trực tiếp bằng nginx/CDN. Không cần Node.js server. Performance tối đa.
Generate routes tự động
Liệt kê 10.000 product URLs bằng tay không realistic. Generate từ API:
// prerender-routes.ts — script chạy trước build
import { writeFileSync } from 'fs';
async function generateRoutes() {
const res = await fetch('https://api.myshop.com/products?fields=slug&limit=10000');
const products = await res.json();
const routes = [
'/',
'/about',
'/contact',
...products.data.map((p: any) => `/products/${p.slug}`),
];
writeFileSync('routes.txt', routes.join('\n'));
console.log(`Generated ${routes.length} routes`);
}
generateRoutes();
// package.json
{
"scripts": {
"prerender:routes": "tsx prerender-routes.ts",
"build": "npm run prerender:routes && ng build"
}
}
Build pipeline: generate route list → Angular prerender mỗi route → output static HTML.
SSR vs Prerender: chọn gì
SSR (Server) Prerender (Static)
──────────────────────────────────────────────────────────────
Khi nào render Mỗi request Lúc build
Performance Tốt (~50-200ms TTFB) Tuyệt vời (~10ms TTFB)
Server cần Node.js runtime Nginx/CDN đủ
Content freshness Real-time Stale until rebuild
Personalization Có (user-specific) Không (cùng HTML cho mọi user)
Phù hợp cho User dashboard, Product page, blog,
search results landing page, about
──────────────────────────────────────────────────────────────
Kết hợp cả hai: prerender trang tĩnh (product catalog, blog), SSR cho trang dynamic (search results, user profile). Angular hỗ trợ mix.
// route config
{
path: 'products/:slug',
component: ProductDetailComponent,
// prerender — HTML tạo lúc build
data: { renderMode: 'prerender' }
},
{
path: 'search',
component: SearchComponent,
// SSR — render mỗi request vì query khác nhau
data: { renderMode: 'server' }
},
{
path: 'dashboard',
component: DashboardComponent,
// CSR only — không cần SEO
data: { renderMode: 'client' }
}
TransferState — tránh duplicate API call
Không có TransferState, flow xảy ra:
- Server render → gọi API lấy product → render HTML
- Browser nhận HTML, hiển thị
- Angular hydrate → component
ngOnInitgọi API lấy product LẠI LẦN NỮA - Product load lần hai → flash content
Duplicate API call + flash UX. TransferState fix bằng cách serialize data vào HTML:
// product.service.ts — with TransferState
import { TransferState, makeStateKey } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
getById(slug: string): Observable<Product> {
const key = makeStateKey<Product>(`product-${slug}`);
// check TransferState trước (browser nhận data từ server)
const cached = this.transferState.get(key, null);
if (cached) {
this.transferState.remove(key); // dùng 1 lần rồi xóa
return of(cached);
}
// fetch từ API (server render hoặc client navigate)
return this.http.get<Product>(`/api/products/${slug}`).pipe(
tap(product => {
// lưu vào TransferState (server → browser transfer)
this.transferState.set(key, product);
})
);
}
}
Server gọi API, lưu kết quả vào TransferState. Angular serialize TransferState thành <script> tag trong HTML. Browser nhận HTML, TransferState đọc data từ script tag, skip API call. Kết quả: một API call duy nhất, không flash, hydration mượt.
Angular 17+ với provideClientHydration(withHttpTransferCacheInterceptor()) tự động cache HTTP responses — không cần viết TransferState thủ công cho HttpClient:
// app.config.ts — automatic HTTP transfer cache
providers: [
provideClientHydration(
withHttpTransferCacheInterceptor() // ← magic
),
provideHttpClient(withFetch()),
]
Mọi HTTP GET request tự động transfer từ server sang client. Không cần sửa service code.
Sitemap và robots.txt
Dynamic sitemap
// server.ts — thêm route generate sitemap
app.get('/sitemap.xml', async (req, res) => {
const products = await fetch('https://api.myshop.com/products?fields=slug,updatedAt&limit=50000')
.then(r => r.json());
const urls = [
{ loc: 'https://myshop.com/', priority: '1.0', changefreq: 'daily' },
{ loc: 'https://myshop.com/about', priority: '0.5', changefreq: 'monthly' },
...products.data.map((p: any) => ({
loc: `https://myshop.com/products/${p.slug}`,
lastmod: p.updatedAt.split('T')[0],
priority: '0.8',
changefreq: 'weekly',
})),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => ` <url>
<loc>${u.loc}</loc>
${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ''}
<changefreq>${u.changefreq}</changefreq>
<priority>${u.priority}</priority>
</url>`).join('\n')}
</urlset>`;
res.header('Content-Type', 'application/xml');
res.send(xml);
});
robots.txt
# src/robots.txt
User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /admin
Disallow: /api/
Sitemap: https://myshop.com/sitemap.xml
Thêm vào angular.json assets:
"assets": [
"src/favicon.ico",
"src/robots.txt"
]
Performance: SSR cache layer
SSR render mỗi request tốn CPU. Trang /products/iphone-15 render giống nhau cho mọi user — không cần render lại mỗi lần:
// server.ts — in-memory cache cho SSR
const ssrCache = new Map<string, { html: string; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 phút
app.get('*', async (req, res, next) => {
const url = req.originalUrl;
// skip cache cho authenticated routes
if (url.startsWith('/dashboard') || url.startsWith('/admin')) {
return renderAndSend(req, res, next);
}
// check cache
const cached = ssrCache.get(url);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
res.setHeader('X-SSR-Cache', 'HIT');
return res.send(cached.html);
}
// render và cache
try {
const html = await commonEngine.render({ /* ... */ });
ssrCache.set(url, { html, timestamp: Date.now() });
res.setHeader('X-SSR-Cache', 'MISS');
res.send(html);
} catch (err) {
next(err);
}
});
X-SSR-Cache: HIT/MISS header — debug nhanh bằng DevTools. Trang đầu tiên MISS (render 100-200ms), request sau HIT (serve <5ms).
Production nên dùng Redis thay in-memory Map — share cache giữa nhiều server instances.
Gotchas — những bẫy thực tế
Third-party library không support SSR
Chart.js, Google Maps, bất kỳ library nào trực tiếp manipulate DOM sẽ crash trên server:
// ❌ crash trên server
import Chart from 'chart.js';
ngOnInit() {
new Chart(this.canvas.nativeElement, {...}); // no DOM on server
}
// ✅ chỉ init trên browser
export class ChartComponent {
constructor() {
afterNextRender(async () => {
const { Chart } = await import('chart.js');
new Chart(this.canvas()!.nativeElement, {...});
});
}
}
afterNextRender + dynamic import() — library chỉ load trên browser, server skip hoàn toàn.
setTimeout / setInterval leak
// ❌ server render không clear interval → memory leak
ngOnInit() {
setInterval(() => this.refresh(), 5000);
}
// ✅ chỉ chạy trên browser + cleanup
private intervalId?: number;
constructor() {
afterNextRender(() => {
this.intervalId = window.setInterval(() => this.refresh(), 5000);
});
}
ngOnDestroy() {
if (this.intervalId) clearInterval(this.intervalId);
}
Timer trên server không bao giờ clear (component không destroy trên server) → memory leak dần dần.
API URL khác giữa server và client
Server chạy trong Docker container — http://localhost:3000/api trên server chỉ đến chính nó, nhưng browser cần https://myshop.com/api:
// app.config.ts
providers: [
{
provide: 'API_BASE_URL',
useFactory: (platformId: Object) => {
return isPlatformServer(platformId)
? 'http://api-service:3000' // internal Docker network
: 'https://myshop.com'; // public URL
},
deps: [PLATFORM_ID]
}
]
SSR tăng server load
Mỗi SSR request = chạy Angular app trên server. 1000 requests/giây = 1000 Angular instances. CPU spike nếu không cache.
Giải pháp: prerender trang tĩnh (giảm SSR load), cache SSR output (Redis/CDN), scale horizontally (nhiều Node.js instances), và CDN edge caching cho trang ít thay đổi.
Deploy SSR
Docker
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --production
EXPOSE 4000
ENV PORT=4000
CMD ["node", "dist/my-app/server/server.mjs"]
nginx reverse proxy
upstream angular_ssr {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name myshop.com;
# static assets serve trực tiếp — không qua SSR
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
root /var/www/my-app/browser;
expires 1y;
add_header Cache-Control "public, immutable";
}
# sitemap, robots.txt
location = /sitemap.xml { proxy_pass http://angular_ssr; }
location = /robots.txt { root /var/www/my-app/browser; }
# SSR cho mọi route khác
location / {
proxy_pass http://angular_ssr;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 5m; # cache SSR output 5 phút
}
}
Static assets (JS, CSS, images) serve bằng nginx — nhanh hơn Node.js rất nhiều. SSR chỉ handle HTML requests.
Kiểm tra SEO hoạt động
# 1. View Source — phải thấy full HTML
curl -s https://myshop.com/products/iphone-15 | head -50
# 2. Check meta tags
curl -s https://myshop.com/products/iphone-15 | grep '<meta'
# 3. Google Rich Results Test
# https://search.google.com/test/rich-results
# paste URL → xem structured data có đúng không
# 4. Facebook Sharing Debugger
# https://developers.facebook.com/tools/debug/
# paste URL → xem og:title, og:image hiện đúng không
# 5. Lighthouse SEO audit
# Chrome DevTools → Lighthouse → check SEO category
Tổng kết
Angular SSR năm 2026 đã mature — ng add @angular/ssr một lệnh, hydration non-destructive, HTTP transfer cache tự động. Không còn khó như thời Angular Universal cũ.
Ba thứ làm ngay cho SEO: bật SSR hoặc prerender cho trang public, dynamic meta tags service cho mỗi trang, và JSON-LD structured data cho product/article. Ba thứ đó cover 80% SEO technical.
Prerender cho trang tĩnh, SSR cho trang dynamic, CSR cho dashboard — mix ba mode trong cùng project. Không phải tất cả mọi trang đều cần SSR — chỉ trang cần Google index và social sharing mới cần.
Nhớ test bằng curl sau deploy — nếu curl thấy content đầy đủ thì Googlebot cũng thấy. Đó là verification đơn giản nhất mà nhiều team bỏ qua.
Leave a comment
Your email address will not be published. Required fields are marked *