Tấn Phát Digital — Bài viết được biên dịch và Việt hóa từ tài liệu chính thức "Understand JavaScript SEO basics" của Google Search Central. Đây là bài kỹ thuật chuyên sâu nhất dành cho frontend developer làm việc với React, Vue, Angular và muốn website được SEO tốt.
SPA — Đẹp với user, "khó hiểu" với Google
Năm 2026, 80% website hiện đại sử dụng JavaScript framework:
React (Facebook ecosystem, Next.js)
Vue (Nuxt.js, growing fast)
Angular (Enterprise, Google's)
Svelte (Modern, lightweight)
Và đây là sự thật phũ phàng:
90% Single-Page Apps (SPA) có vấn đề SEO nghiêm trọng.
Vấn đề điển hình:
Google không thấy content (load qua JS)
Title/description không thay đổi
Soft 404 errors
Routing không crawlable
Structured data bị miss
Bài viết này, Tấn Phát Digital sẽ hướng dẫn frontend developer cách build SPA vừa đẹp UX, vừa SEO-friendly.
Bài viết này dành cho:
Frontend developer (React, Vue, Angular)
Full-stack developer
Technical SEO chuyên về JS sites
Engineering manager quản lý frontend team
Phần 1: Google xử lý JavaScript như thế nào?
1.1. 3 phases của Google
Google nói rõ:
"Google processes JavaScript web apps trong 3 main phases: Crawling, Rendering, Indexing."
Sơ đồ:
URL trong Crawl Queue
↓
[Phase 1: CRAWLING]
↓
Check robots.txt
↓
Fetch HTML
↓
Parse for <a href> links
↓
┌─────────────────────┐
│ Add links to │
│ crawl queue │
└─────────────────────┘
↓
Queue page cho rendering
↓
[Phase 2: RENDERING]
↓
Headless Chromium executes JS
↓
Generate rendered HTML
↓
[Phase 3: INDEXING]
↓
Parse rendered HTML
↓
Extract content + links
↓
Index!
1.2. Điểm quan trọng
Point 1: Rendering tốn thời gian
"Page có thể stay trong queue vài giây, nhưng có thể TAKE LONGER."
→ Rendering chậm hơn crawling thông thường nhiều lần.
Point 2: Server-side rendering vẫn tốt hơn
"Server-side hoặc pre-rendering vẫn là great idea vì nó makes website FASTER cho cả users và crawlers, và NOT ALL BOTS có thể run JavaScript."
→ SSR/SSG luôn tốt hơn CSR cho SEO.
Point 3: Robots.txt vẫn áp dụng
"Google Search WON'T RENDER JavaScript từ blocked files hoặc on blocked pages."
→ Đừng block JS/CSS trong robots.txt.
Point 4: Status code quan trọng
"All pages with 200 HTTP status code được sent to rendering queue. Nếu HTTP status code non-200, rendering MIGHT BE SKIPPED."
→ Đảm bảo URLs return 200 OK.
1.3. Rendering strategy comparison
Strategy | SEO | Performance | Complexity |
|---|---|---|---|
CSR (Client-Side) | Kém | Initial load chậm | Đơn giản |
SSR (Server-Side) | Tốt | Tốt | Phức tạp |
SSG (Static Generation) | Tốt nhất | Tốt nhất | Trung bình |
ISR (Incremental Static) | Tốt nhất | Tốt nhất | Trung bình |
Hybrid | Tốt | Tốt | Phức tạp nhất |
Khuyến nghị:
Next.js: SSG/ISR (default)
Nuxt.js: SSR/SSG
SvelteKit: SSR
Pure React: Tránh CSR nếu cần SEO
Phần 2: Best Practice — Unique Titles và Snippets
Google nhấn mạnh:
"Unique, descriptive
<title>elements và meta descriptions giúp users quickly identify the best result. You CAN USE JAVASCRIPT để set hoặc change meta description cũng như<title>element."
2.1. Implementation với React
// Cách 1: react-helmet
import { Helmet } from 'react-helmet-async';
function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name} | Shop XYZ</title>
<meta name="description" content={product.description} />
<link rel="canonical" href={`https://shop.com/products/${product.slug}`} />
</Helmet>
{/* Page content */}
</>
);
}
// Cách 2: Next.js
import Head from 'next/head';
function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.name} | Shop XYZ</title>
<meta name="description" content={product.description} />
<link rel="canonical" href={`https://shop.com/products/${product.slug}`} />
</Head>
{/* Page content */}
</>
);
}
2.2. Implementation với Vue
<!-- Cách 1: Vue meta -->
<template>
<div>{{ product.name }}</div>
</template>
<script>
export default {
metaInfo() {
return {
title: `${this.product.name} | Shop XYZ`,
meta: [
{ name: 'description', content: this.product.description }
],
link: [
{ rel: 'canonical', href: `https://shop.com/products/${this.product.slug}` }
]
};
}
};
</script>
<!-- Cách 2: Nuxt 3 -->
<script setup>
const { product } = defineProps(['product']);
useHead({
title: `${product.name} | Shop XYZ`,
meta: [
{ name: 'description', content: product.description }
],
link: [
{ rel: 'canonical', href: `https://shop.com/products/${product.slug}` }
]
});
</script>
2.3. Implementation với Angular
// product.component.ts
import { Component, OnInit } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
@Component({...})
export class ProductComponent implements OnInit {
product: any;
constructor(
private titleService: Title,
private metaService: Meta
) {}
ngOnInit() {
this.loadProduct().then(product => {
this.product = product;
this.titleService.setTitle(`${product.name} | Shop XYZ`);
this.metaService.updateTag({
name: 'description',
content: product.description
});
});
}
}
Phần 3: Canonical URLs với JavaScript
Google nói:
"You CAN USE JavaScript để set canonical URL, nhưng keep in mind rằng bạn shouldn't use JavaScript để CHANGE canonical URL to something DIFFERENT từ URL bạn specified trong original HTML."
3.1. Khuyến nghị
✅ Best practice: Set canonical trong HTML gốc (server-side)
✅ Alternative: Set bằng JS, CHỈ MỘT canonical, KHÔNG conflict với HTML
❌ Tránh: Change canonical với JS nếu HTML đã có canonical khác
3.2. Code example (Google's official)
fetch('/api/cats/' + id)
.then(function (response) { return response.json(); })
.then(function (cat) {
// Tạo canonical link tag động
const linkTag = document.createElement('link');
linkTag.setAttribute('rel', 'canonical');
linkTag.href = 'https://example.com/cats/' + cat.urlFriendlyName;
document.head.appendChild(linkTag);
});
⚠️ Lưu ý:
Make sure CHỈ 1 canonical tag
KHÔNG conflict với existing canonical
Test với URL Inspection Tool
Phần 4: HTTP Status Codes cho SPAs
Đây là vấn đề rất phổ biến với SPAs.
4.1. Vấn đề Soft 404
Trong SPA:
User navigate đến
/product/non-existent-idReact Router render error component
Nhưng HTTP status vẫn là 200 OK
Google index trang error
→ Đây là Soft 404 — Google không thích.
4.2. 2 giải pháp Google khuyến nghị
Giải pháp 1: JavaScript redirect đến 404 page
fetch(`/api/products/${productId}`)
.then(response => response.json())
.then(product => {
if(product.exists) {
showProductDetails(product); // Show product info
} else {
// Product không tồn tại - redirect đến server's 404 page
window.location.href = '/not-found';
// Server's /not-found returns HTTP 404
}
})
.catch(err => {
window.location.href = '/not-found';
});
Server-side (Next.js):
// pages/not-found.js
export default function NotFound() {
return <h1>404 - Page Not Found</h1>;
}
// pages/_error.js hoặc trong server
export async function getServerSideProps({ res }) {
res.statusCode = 404;
return { props: {} };
}
Giải pháp 2: Inject noindex meta tag
fetch(`/api/products/${productId}`)
.then(response => response.json())
.then(product => {
if(product.exists) {
showProductDetails(product);
} else {
// Add noindex meta tag dynamically
const metaRobots = document.createElement('meta');
metaRobots.name = 'robots';
metaRobots.content = 'noindex';
document.head.appendChild(metaRobots);
// Show error message
showErrorMessage('Product not found');
}
})
4.3. So sánh 2 approaches
Approach | Ưu | Nhược |
|---|---|---|
JS Redirect | True 404 status | Extra redirect |
noindex tag | No redirect | Vẫn 200 status |
Khuyến nghị: Giải pháp 1 (redirect) tốt hơn cho SEO.
Phần 5: History API thay vì Fragments
Đây là quy tắc CỰC KỲ quan trọng cho SPA.
5.1. Vấn đề với Fragments (#)
Google nói:
"Google can ONLY discover your links nếu chúng là
<a>HTML elements vớihrefattribute."
Ví dụ BAD practice:
<nav>
<ul>
<li><a href="#/products">Our products</a></li>
<li><a href="#/services">Our services</a></li>
</ul>
</nav>
<script>
window.addEventListener('hashchange', function() {
const pageToLoad = window.location.hash.slice(1);
document.getElementById('placeholder').innerHTML = load(pageToLoad);
});
</script>
🚨 Vấn đề: Google KHÔNG resolve fragments (#) → KHÔNG crawl được pages.
5.2. Giải pháp: History API
Code GOOD practice (Google's official):
<nav>
<ul>
<li><a href="/products">Our products</a></li>
<li><a href="/services">Our services</a></li>
</ul>
</nav>
<script>
function goToPage(event) {
event.preventDefault();
const hrefUrl = event.target.getAttribute('href');
const pageToLoad = hrefUrl.slice(1);
document.getElementById('placeholder').innerHTML = load(pageToLoad);
// Update URL + browser history
window.history.pushState({}, window.title, hrefUrl);
}
document.querySelectorAll('a').forEach(link =>
link.addEventListener('click', goToPage)
);
</script>
5.3. Implementation trong frameworks
React Router
// React Router v6 dùng History API by default
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter> {/* Dùng History API */}
<nav>
<Link to="/products">Products</Link> {/* Tạo <a href> crawlable */}
<Link to="/services">Services</Link>
</nav>
<Routes>
<Route path="/products" element={<Products />} />
<Route path="/services" element={<Services />} />
</Routes>
</BrowserRouter>
);
}
❌ Tránh HashRouter:
// SAI - dùng fragments
import { HashRouter } from 'react-router-dom';
// URL sẽ là: /#/products → Google không crawl được
Vue Router
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(), // ✅ Dùng History API
// history: createWebHashHistory(), // ❌ Tránh - dùng fragments
routes: [...]
});
Angular Router
Angular Router default dùng History API. Không cần config thêm.
Phần 6: Robots Meta Tags với JavaScript
6.1. Cảnh báo quan trọng
Google warning:
"When Google encounters the
noindextag, it MAY SKIP rendering và JavaScript execution, nghĩa là dùng JavaScript để CHANGE hoặc REMOVE noindex từ noindex MAY NOT WORK as expected."
🚨 Quan trọng:
Nếu HTML gốc có
noindex→ Google có thể không render JS→ Đổi
noindexthànhindexbằng JS có thể không hiệu quả
6.2. Best practice
✅ Nếu muốn page được index:
KHÔNG dùng
noindextrong HTML gốcKHÔNG dựa vào JS để remove noindex
✅ Nếu muốn dynamic noindex (vd: trang error):
HTML gốc KHÔNG có noindex
JS thêm noindex KHI cần
6.3. Code example (Google's official)
fetch('/api/products/' + productId)
.then(function (response) { return response.json(); })
.then(function (apiResponse) {
if (apiResponse.isError) {
// Get existing robots meta tag
var metaRobots = document.querySelector('meta[name="robots"]');
// Create if doesn't exist
if (!metaRobots) {
metaRobots = document.createElement('meta');
metaRobots.setAttribute('name', 'robots');
document.head.appendChild(metaRobots);
}
// Set to noindex
metaRobots.setAttribute('content', 'noindex');
// Show error message
errorMsg.textContent = 'This product is no longer available';
return;
}
// Display product normally
displayProduct(apiResponse);
});
Phần 7: Long-lived Caching với Fingerprinting
7.1. Vấn đề Google caching aggressive
Google nói:
"Googlebot caches aggressively để reduce network requests. WRS MAY IGNORE caching headers. Điều này có thể lead WRS to use OUTDATED JavaScript hoặc CSS resources."
→ Google có thể dùng JS/CSS cũ → render sai → ranking giảm.
7.2. Giải pháp: Content fingerprinting
Content fingerprinting = Đặt hash của content vào filename.
Trước fingerprinting:
main.js ← Browser/Google có thể cache lâu, không biết khi nào file đổi
styles.css
Sau fingerprinting:
main.2bb85551.js ← Khi content đổi, filename đổi → force re-fetch
main.8b9f3a12.js ← Version mới
styles.5d2e1c34.css
7.3. Implementation
Webpack
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js',
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
],
};
Vite
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
};
Next.js
Next.js tự động fingerprint mọi assets. Không cần config.
Phần 8: Structured Data với JavaScript
Google cho phép:
"You can use JavaScript để generate required JSON-LD và inject it into page."
8.1. Code example
// Generate Product schema dynamically
fetch(`/api/products/${productId}`)
.then(response => response.json())
.then(product => {
const schema = {
"@context": "https://schema.org/",
"@type": "Product",
"name": product.name,
"image": product.image,
"description": product.description,
"offers": {
"@type": "Offer",
"price": product.price,
"priceCurrency": "VND",
"availability": product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock"
}
};
// Inject into <head>
const script = document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify(schema);
document.head.appendChild(script);
});
8.2. Test implementation
LUÔN test:
Rich Results Test — https://search.google.com/test/rich-results
URL Inspection trong Search Console
→ Confirm Google thấy structured data sau JS render.
Phần 9: Web Components và Shadow DOM
Google support Web Components:
"Khi Google renders a page, nó FLATTENS the shadow DOM và light DOM content."
→ Content trong shadow DOM có thể được index nếu setup đúng.
9.1. Slot element
Google đưa ví dụ chi tiết:
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
let p = document.createElement('p');
p.innerHTML = 'Hello World, this is shadow DOM content. Here comes the light DOM: <slot></slot>';
this.shadowRoot.appendChild(p);
}
}
window.customElements.define('my-component', MyComponent);
</script>
<my-component>
<p>This is light DOM content. It's projected into shadow DOM.</p>
<p>WRS renders this content cũng như shadow DOM content.</p>
</my-component>
Sau khi render, Google index:
<my-component>
Hello World, this is shadow DOM content. Here comes the light DOM:
<p>This is light DOM content. It's projected into shadow DOM.</p>
<p>WRS renders this content cũng như shadow DOM content.</p>
</my-component>
9.2. Best practice
✅ Đảm bảo content quan trọng visible trong rendered HTML ✅ Test với Rich Results Test ✅ Dùng Slot element cho light DOM projection
❌ Tránh content chỉ trong shadow DOM mà không có slot
Phần 10: Lazy Loading Images
Google khuyến nghị:
"Images có thể quite costly trên bandwidth và performance. Good strategy là use lazy-loading."
10.1. Native lazy loading (Recommended)
<!-- Modern approach - HTML5 native -->
<img
src="product.jpg"
alt="Áo thun nam cotton trắng"
loading="lazy"
width="600"
height="400">
Browser support: 95%+ (Chrome, Firefox, Safari, Edge).
10.2. JavaScript lazy loading
Intersection Observer API:
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
});
});
images.forEach(img => imageObserver.observe(img));
<img
data-src="product.jpg"
src="placeholder.jpg"
alt="Áo thun nam cotton trắng">
10.3. React lazy loading
// Modern - native HTML
function ProductImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy"
width={600}
height={400}
/>
);
}
// Next.js Image component (built-in optimization)
import Image from 'next/image';
function ProductImage({ src, alt }) {
return (
<Image
src={src}
alt={alt}
width={600}
height={400}
loading="lazy" // default
/>
);
}
10.4. Guidelines từ Google
✅ DO:
Lazy load below-the-fold images
Có proper width/height
Có alt text
Có placeholder/blur
❌ DON'T:
Lazy load above-the-fold images
Block content cho đến khi load
Forget alt text
Lazy load critical images
Phần 11: Debugging JavaScript SEO
11.1. Tools quan trọng
URL Inspection Tool (Search Console)
Bước:
Search Console → URL Inspection
Paste URL
Click "View tested page"
Xem Screenshot + Rendered HTML
Sẽ cho thấy:
Google thấy gì sau render
Có errors không
JavaScript có execute không
Rich Results Test
URL: https://search.google.com/test/rich-results
Test structured data + rendered HTML.
Chrome DevTools
// Test rendering như Googlebot
// 1. DevTools → Network → Disable cache
// 2. Throttling: Slow 3G
// 3. Disable JavaScript → reload → see what Google might see
Lighthouse
# Run Lighthouse audit
lighthouse https://your-site.com --view
→ Check SEO score, opportunities.
11.2. Common errors
Error | Nguyên nhân | Fix |
|---|---|---|
Content not indexed | CSR without SSR | Implement SSR/SSG |
Soft 404 errors | Status 200 cho error page | Return real 404 |
Duplicate titles | Title không update | Use react-helmet/Head |
Missing canonical | Quên set | Add canonical tag |
Links not crawled | Dùng button instead of | Use proper |
Slow rendering | Bundle quá lớn | Code splitting |
Phần 12: Framework-specific Best Practices
12.1. Next.js (React) — Recommended
Khuyến nghị từ Tấn Phát Digital:
// Use SSG cho content tĩnh
export async function getStaticProps() {
const data = await fetchData();
return { props: { data }, revalidate: 60 };
}
// Use SSR cho dynamic content
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
if (!product) {
return { notFound: true }; // Returns 404
}
return { props: { product } };
}
// Use Image component
import Image from 'next/image';
// Use Head component
import Head from 'next/head';
12.2. Nuxt.js (Vue)
<script setup>
// SSR by default
const { data } = await useFetch('/api/products');
useHead({
title: 'Products | Shop XYZ',
meta: [
{ name: 'description', content: 'Browse our products' }
]
});
</script>
12.3. Angular Universal
// Setup Angular Universal cho SSR
// app.server.module.ts
import { ServerModule } from '@angular/platform-server';
@NgModule({
imports: [AppModule, ServerModule],
bootstrap: [AppComponent]
})
export class AppServerModule {}
12.4. SvelteKit
// +page.server.js - SSR by default
export async function load({ params }) {
const product = await fetchProduct(params.id);
if (!product) {
error(404, 'Not found');
}
return { product };
}
Phần 13: Checklist JavaScript SEO
Pre-development
[ ] Chọn framework với SSR/SSG support
[ ] Plan rendering strategy
[ ] Setup proper routing (History API)
[ ] Plan content fingerprinting
Development
[ ] Implement unique title cho mỗi page
[ ] Implement meta description dynamic
[ ] Set canonical URLs đúng cách
[ ] Use proper HTTP status codes
[ ] Implement structured data
[ ] Use
<a href>cho navigation[ ] Avoid fragments (#) cho routing
[ ] Native lazy loading cho images
[ ] Proper alt text
Testing
[ ] Test với URL Inspection Tool
[ ] Test với Rich Results Test
[ ] Lighthouse audit
[ ] Test với JS disabled (fallback)
[ ] Test crawl với Screaming Frog
Deployment
[ ] Setup Search Console
[ ] Submit sitemap
[ ] Monitor crawl stats
[ ] Monitor errors
Kết luận
JavaScript SEO không phải bất khả thi — chỉ cần làm đúng cách. Với frameworks hiện đại (Next.js, Nuxt, SvelteKit), SEO-friendly SPAs trở nên dễ dàng hơn nhiều.
5 thông điệp cuối
1. SSR/SSG luôn tốt hơn pure CSR cho SEO.
2. Use History API, tránh fragments cho routing.
3. Proper status codes — handle 404 đúng cách.
4. Test với URL Inspection — confirm Google thấy gì.
5. Content fingerprinting cho aggressive caching.
Tài liệu tham khảo
Về Tấn Phát Digital
Tấn Phát Digital chuyên về JavaScript SEO cho:
React/Next.js applications
Vue/Nuxt applications
Angular enterprise apps
SPA migration to SSR
JavaScript SEO audits
Liên hệ để optimize JS site của bạn.
Biên soạn từ Google Search Central, 04/03/2026. Code samples và framework guidance thuộc về Tấn Phát Digital.
JavaScript SEO là nền tảng quan trọng giúp website hiện đại vừa có trải nghiệm tốt vừa thân thiện với Google Search.
Nếu bạn muốn xây dựng website React, Vue hoặc Next.js chuẩn SEO, hãy liên hệ Tấn Phát Digital để được tư vấn.









