Phần lớn website hiện đại đều dựng trên JavaScript framework — React (Next.js), Vue (Nuxt), Angular, Svelte. Nhưng sự thật phũ phàng: rất nhiều Single-Page App (SPA) gặp vấn đề SEO nghiêm trọng. Google không thấy nội dung (vì tải qua JS), title/description không đổi giữa các trang, soft 404 tràn lan, routing không crawl được, structured data bị bỏ sót.
Bài viết này hướng dẫn frontend developer cách xây SPA vừa đẹp về UX, vừa thân thiện với Google. Bài dành cho: frontend developer (React, Vue, Angular), full-stack developer, technical SEO chuyên về JS site, và engineering manager quản lý team frontend.
Phần 1: Google xử lý JavaScript như thế nào?
Google xử lý web app JavaScript qua ba giai đoạn:
Crawling — kiểm tra robots.txt, fetch HTML, phân tích các link
<a href>và đưa vào crawl queue, rồi xếp trang vào hàng đợi render.Rendering — headless Chromium thực thi JavaScript, tạo ra HTML đã render.
Indexing — phân tích HTML đã render, trích xuất nội dung và link, rồi index.
Bốn điểm cần nhớ:
Rendering tốn thời gian. Trang có thể nằm trong hàng đợi vài giây — nhưng cũng có thể lâu hơn nhiều. Render luôn chậm hơn crawl thông thường.
Server-side rendering vẫn tốt hơn. Google nói rõ SSR hoặc pre-rendering vẫn là ý tưởng hay vì nó làm website nhanh hơn cho cả người dùng lẫn crawler, và không phải bot nào cũng chạy được JavaScript.
Robots.txt vẫn áp dụng. Google sẽ không render JavaScript từ file hoặc trang bị chặn — đừng chặn JS/CSS trong robots.txt.
Status code quan trọng. Mọi trang trả về 200 OK mới được đưa vào hàng đợi render. Nếu status khác 200, việc render có thể bị bỏ qua.
Chiến lược render | SEO | Hiệu năng | Độ phức tạp |
|---|---|---|---|
CSR (Client-Side) | Kém | Tải lần đầu 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; Nuxt → SSR/SSG; SvelteKit → SSR; React thuần → tránh CSR nếu cần SEO.
Phần 2: Title và meta description động
Google xác nhận: bạn có thể dùng JavaScript để đặt hoặc thay đổi <title> và meta description. Điều quan trọng là mỗi trang phải có title/description độc nhất và mô tả đúng.
javascript
// React — 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>
);
}javascript
// Next.js
import Head from 'next/head';
<Head>
<title>{product.name} | Shop XYZ</title>
<meta name="description" content={product.description} />
<link rel="canonical" href={`https://shop.com/products/${product.slug}`} />
</Head>javascript
// Nuxt 3
useHead({
title: `${product.name} | Shop XYZ`,
meta: [{ name: 'description', content: product.description }],
link: [{ rel: 'canonical', href: `https://shop.com/products/${product.slug}` }]
});typescript
// Angular
import { Title, Meta } from '@angular/platform-browser';
constructor(private titleService: Title, private metaService: Meta) {}
ngOnInit() {
this.loadProduct().then(product => {
this.titleService.setTitle(`${product.name} | Shop XYZ`);
this.metaService.updateTag({ name: 'description', content: product.description });
});
}Phần 3: Canonical URL với JavaScript
Bạn có thể dùng JavaScript để đặt canonical URL — nhưng không nên dùng JS để đổi canonical thành một URL khác với URL đã khai báo trong HTML gốc.
Tốt nhất: đặt canonical ngay trong HTML gốc (server-side). Nếu buộc phải đặt bằng JS, chỉ được có duy nhất một canonical tag và không xung đột với HTML.
javascript
fetch('/api/cats/' + id)
.then(response => response.json())
.then(cat => {
const linkTag = document.createElement('link');
linkTag.setAttribute('rel', 'canonical');
linkTag.href = 'https://example.com/cats/' + cat.urlFriendlyName;
document.head.appendChild(linkTag);
});Phần 4: HTTP status code cho SPA — vấn đề Soft 404
Đây là lỗi phổ biến nhất của SPA: người dùng vào /product/id-khong-ton-tai, React Router render component lỗi — nhưng HTTP status vẫn là 200 OK. Google index trang lỗi đó. Đây chính là soft 404.
Giải pháp | Ưu điểm | Nhược điểm |
|---|---|---|
Redirect sang trang 404 thật | Trả đúng status 404 | Có thêm một lần redirect |
Chèn thẻ | Không cần redirect | Vẫn trả status 200 |
Khuyến nghị: dùng cách 1 (redirect) vì tốt hơn cho SEO — và đây cũng là nền tảng của chiến lược xử lý lỗi 404 đúng chuẩn.
javascript
// Cách 1: redirect về trang 404 do server trả về
fetch(`/api/products/${productId}`)
.then(response => response.json())
.then(product => {
if (product.exists) {
showProductDetails(product);
} else {
window.location.href = '/not-found'; // server trả HTTP 404
}
})
.catch(() => { window.location.href = '/not-found'; });javascript
// Next.js: trả 404 thật từ server
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
if (!product) {
return { notFound: true }; // → HTTP 404
}
return { props: { product } };
}Phần 5: Dùng History API, KHÔNG dùng fragment (#)
Đây là quy tắc cực kỳ quan trọng. Google chỉ khám phá được link nếu chúng là thẻ <a> có thuộc tính href — và Google không resolve fragment (#), nên routing kiểu #/products sẽ không được crawl.
html
<!-- SAI: routing bằng fragment → Google không crawl được -->
<a href="#/products">Sản phẩm</a>html
<!-- ĐÚNG: link thật + History API -->
<nav>
<a href="/products">Sản phẩm</a>
<a href="/services">Dịch vụ</a>
</nav>
<script>
function goToPage(event) {
event.preventDefault();
const hrefUrl = event.target.getAttribute('href');
document.getElementById('placeholder').innerHTML = load(hrefUrl.slice(1));
window.history.pushState({}, window.title, hrefUrl); // cập nhật URL
}
document.querySelectorAll('a').forEach(link => link.addEventListener('click', goToPage));
</script>Trong các framework:
javascript
// React Router v6 — BrowserRouter dùng History API, <Link> render ra <a href> crawlable
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
<BrowserRouter>
<Link to="/products">Products</Link>
</BrowserRouter>
// TRÁNH: HashRouter → URL dạng /#/products → Google không crawl đượcjavascript
// Vue Router
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(), // ĐÚNG
// history: createWebHashHistory(), // TRÁNH — dùng fragment
routes: [/* ... */]
});Angular Router mặc định đã dùng History API — không cần cấu hình thêm.
Phần 6: Thẻ robots meta với JavaScript
Cảnh báo quan trọng: khi Google gặp thẻ noindex, nó có thể bỏ qua việc render và thực thi JavaScript. Nghĩa là dùng JS để gỡ noindex có thể không hoạt động như bạn mong đợi.
Do đó: nếu muốn trang được index, đừng để noindex trong HTML gốc và đừng trông cậy vào JS để gỡ nó. Ngược lại, nếu cần noindex động (ví dụ trang lỗi), hãy để HTML gốc không có noindex và dùng JS thêm vào khi cần:
javascript
fetch('/api/products/' + productId)
.then(response => response.json())
.then(apiResponse => {
if (apiResponse.isError) {
let metaRobots = document.querySelector('meta[name="robots"]');
if (!metaRobots) {
metaRobots = document.createElement('meta');
metaRobots.setAttribute('name', 'robots');
document.head.appendChild(metaRobots);
}
metaRobots.setAttribute('content', 'noindex');
errorMsg.textContent = 'Sản phẩm này không còn khả dụng';
return;
}
displayProduct(apiResponse);
});Phần 7: Content Fingerprinting
Googlebot cache rất tích cực để giảm request mạng, và hệ thống render có thể bỏ qua caching header — dẫn tới việc dùng JS/CSS cũ, render sai, và ảnh hưởng thứ hạng.
Giải pháp: content fingerprinting — nhúng hash của nội dung vào tên file, để khi nội dung đổi thì tên file cũng đổi, buộc phải fetch lại (main.js → main.2bb85551.js).
javascript
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js',
},
};javascript
// 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 tự động fingerprint mọi asset — không cần cấu hình.
Phần 8: Structured Data với JavaScript
Google cho phép dùng JavaScript để sinh JSON-LD và inject vào trang:
javascript
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"
}
};
const script = document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify(schema);
document.head.appendChild(script);
});Luôn kiểm tra bằng Rich Results Test và URL Inspection trong Search Console để xác nhận Google thấy structured data sau khi render — chi tiết trong checklist Technical SEO.
Phần 9: Web Components và Shadow DOM
Khi render, Google làm phẳng (flatten) shadow DOM và light DOM — nên nội dung trong shadow DOM vẫn có thể được index nếu setup đúng.
html
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const p = document.createElement('p');
p.innerHTML = 'Nội dung shadow DOM. Và đây là light DOM: <slot></slot>';
this.shadowRoot.appendChild(p);
}
}
window.customElements.define('my-component', MyComponent);
</script>
<my-component>
<p>Đây là nội dung light DOM, được chiếu vào shadow DOM qua slot.</p>
</my-component>Nguyên tắc: đảm bảo nội dung quan trọng hiện diện trong HTML đã render; dùng <slot> để chiếu light DOM; tránh để nội dung chỉ nằm trong shadow DOM mà không có slot.
Phần 10: Lazy Loading hình ảnh
Ảnh tốn nhiều băng thông, nên lazy-loading là chiến lược tốt. Ưu tiên native lazy loading:
html
<img src="product.jpg"
alt="Áo thun nam cotton trắng"
loading="lazy"
width="600" height="400">Hoặc dùng Intersection Observer khi cần kiểm soát chi tiết:
javascript
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);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => imageObserver.observe(img));NÊN: lazy-load ảnh dưới màn hình đầu; luôn có width/height; có alt text; có placeholder. KHÔNG NÊN: lazy-load ảnh above-the-fold (đặc biệt là ảnh LCP, vì sẽ làm chậm Core Web Vitals); chặn nội dung cho tới khi ảnh tải xong.
Phần 11: Debug JavaScript SEO
Công cụ chính: URL Inspection Tool (Search Console → dán URL → "View tested page" → xem screenshot và HTML đã render — cách chắc chắn nhất để biết Google thấy gì); Rich Results Test (kiểm tra structured data + HTML render); Chrome DevTools (tắt JS rồi reload để xem trang trông thế nào khi không có JS); và Lighthouse.
Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
Nội dung không được index | CSR không có SSR | Triển khai SSR/SSG |
Soft 404 | Trang lỗi trả status 200 | Trả 404 thật |
Title trùng lặp | Title không cập nhật theo route | Dùng react-helmet / |
Thiếu canonical | Quên khai báo | Thêm canonical tag |
Link không được crawl | Dùng | Dùng |
Render chậm | Bundle quá lớn | Code splitting |
Phần 12: Best practice theo framework
javascript
// Next.js — SSG cho nội dung tĩnh
export async function getStaticProps() {
const data = await fetchData();
return { props: { data }, revalidate: 60 };
}
// SSR cho nội dung động + xử lý 404 đúng cách
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
if (!product) return { notFound: true };
return { props: { product } };
}javascript
// Nuxt 3 — SSR mặc định
const { data } = await useFetch('/api/products');
useHead({
title: 'Products | Shop XYZ',
meta: [{ name: 'description', content: 'Browse our products' }]
});javascript
// SvelteKit — SSR mặc định, trả 404 chuẩn
export async function load({ params }) {
const product = await fetchProduct(params.id);
if (!product) error(404, 'Not found');
return { product };
}Với Angular, dùng Angular Universal để bật SSR.
Phần 13: Checklist JavaScript SEO
Giai đoạn | Cần kiểm tra |
|---|---|
Trước khi code | Chọn framework hỗ trợ SSR/SSG; xác định chiến lược render; routing dùng History API; lên kế hoạch fingerprinting |
Khi phát triển | Title/description độc nhất mỗi trang; canonical đúng; HTTP status đúng; structured data; dùng |
Kiểm thử | URL Inspection Tool; Rich Results Test; Lighthouse; test khi tắt JS; crawl thử bằng Screaming Frog |
Triển khai | Thiết lập Search Console; submit sitemap; theo dõi crawl stats và lỗi |
Kết luận
JavaScript SEO không phải bất khả thi — chỉ cần làm đúng cách. Với các framework hiện đại (Next.js, Nuxt, SvelteKit), việc xây SPA thân thiện SEO đã dễ hơn rất nhiều. Năm thông điệp cốt lõi: (1) SSR/SSG luôn tốt hơn CSR thuần cho SEO; (2) dùng History API, tránh fragment cho routing; (3) trả đúng status code — xử lý 404 tử tế; (4) test bằng URL Inspection để biết Google thực sự thấy gì; và (5) dùng content fingerprinting vì Google cache rất tích cực.
JavaScript SEO là nền tả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.
Biên soạn và Việt hóa từ tài liệu Google Search Central về JavaScript SEO. Code sample và hướng dẫn theo framework thuộc về Tấn Phát Digital.







