Search

Angular SSR Với Angular 17+: SEO Cho SPA

Angular SSR Với Angular 17+: SEO Cho SPA

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/ssr package
  • 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êm provideClientHydration()
  • 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à:

  1. Server render HTML đầy đủ → gửi cho browser
  2. Browser hiển thị HTML ngay (user thấy content)
  3. Angular JavaScript load, bootstrap
  4. 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.

Dynamic Meta Tags — cốt lõi của SEO

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:

  1. Server render → gọi API lấy product → render HTML
  2. Browser nhận HTML, hiển thị
  3. Angular hydrate → component ngOnInit gọi API lấy product LẠI LẦN NỮA
  4. 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.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy