

1/1/1970
Cách mình triển khai SSG trong 2 ngày
Chào mọi người! Cách đây một năm mình còn nghĩ chuyện này là bất khả thi. Nhưng mình vừa triển khai xong Static Site Generation (SSG) cho Foony trong 2 ngày, và mình khá phấn khích về nó. Đây cũng không phải lần đầu mình cố gắng giải bài toán SSG cho Foony. Trước đây mình đã xem qua NextJS, Vike, Astro, Gatsby và vài giải pháp khác. Mình thậm chí còn có một lần "khởi động hụt" với NextJS, nhưng nhanh chóng gặp khó khăn vì độ phức tạp của SPA của Foony và hàng ngàn file. Việc migrate sẽ là một cơn ác mộng và chắc chắn mất vài tháng. Nó cũng sẽ làm mọi thứ phức tạp hơn cho mọi người khác đang làm việc trên site vì họ sẽ phải học NextJS và đủ kiểu quirks của nó.
Mình muốn một thứ gì đó nhẹ, dễ triển khai. Một thứ cho phép bọn mình tiếp tục viết code như trước giờ vẫn viết mà gần như không cần nghĩ đến SSG (trừ useMediaQuery ra thì… không có đường tắt đâu). Bên dưới mình sẽ chia nhỏ lý do vì sao mình chọn giải pháp "đặt may", những vấn đề cụ thể mình gặp (nhất là với Suspense boundary của React), và cách mình giải quyết chúng.
Tại sao không dùng giải pháp "chuẩn"?
Khi lần đầu mình nghĩ đến việc thêm SSG vào Foony, việc tự nhiên nhất là cân nhắc NextJS (tiêu chuẩn ngành), Vike và Astro.
NextJS: Quá nhiều thứ phải migrate
NextJS rất mạnh, nhưng nó sẽ đòi hỏi phải migrate một khối lượng khổng lồ từ SPA React hiện tại của Foony. Bọn mình có hàng ngàn file, logic routing phức tạp và rất nhiều hạ tầng tự làm. Migrate sang NextJS sẽ đồng nghĩa với:
- Viết lại toàn bộ hệ thống routing
- Cấu trúc lại cách load game và component
- Mất vài tháng chỉ để quay lại đúng mức tính năng hiện tại
- Nguy cơ gây ra các thay đổi phá vỡ trải nghiệm người dùng
- Phải thay đổi cách xử lý ảnh
- Thời gian build chậm đi đáng kể (có thể 5-30 phút. Mình không có con số cụ thể ngoài cuộc thảo luận trên GitHub từ 5 năm trước)
- Cả team phải học một thứ mới (NextJS) và chấp nhận tốc độ phát triển chậm hơn mãi mãi
- Cứ mỗi lần NextJS quyết định "bẻ gãy" API là lại phải migrate code thêm một lần nữa
Mình thậm chí đã thử bắt đầu với NextJS, nhưng nhanh chóng nhận ra chi phí migration quá cao. Độ phức tạp mang lại không đáng chút nào.
Vike: Độ phức tạp tương tự
Vike (trước đây là vite-plugin-ssr) cũng gặp vấn đề tương tự. Dù nó linh hoạt hơn NextJS, nó vẫn sẽ yêu cầu phải tái cấu trúc kha khá codebase của bọn mình. Đường cong học tập và công sức migrate không xứng với lợi ích nhận lại.
Astro: Sai kiến trúc
Astro rất tuyệt cho những site nặng nội dung, nhưng Foony là một nền tảng game multiplayer phức tạp. Bọn mình cần cập nhật thời gian thực, kết nối WebSocket và những component React động. Kiến trúc của Astro đơn giản là không hợp với thứ bọn mình đang xây.
Giải pháp: SSG "đặt may"
Được tiếp thêm tự tin từ cách làm "fake SSG" mình vừa triển khai vài ngày trước sau i18n, mình quyết định chọn một giải pháp SSG nhỏ gọn, nhẹ nhàng, thiết kế riêng cho Foony.
Cách "fake SSG" của mình là: lấy nội dung blog post từ các trang có blog post (các route
/postsvà trang game), rồi đặt chúng đúng vị trí mà client sẽ render, chủ yếu để các công cụ tìm kiếm và LLM hiểu Foony tốt hơn. Nó cũng gắn thêm schema ld+json và một chút SEO lặt vặt.
Cách tiếp cận khá đơn giản:
- Xây thêm lên trên SPA React hiện tại: Không cần migrate, chỉ việc thêm bước tạo SSG lúc build.
- Dùng
renderToReadableStream: API streaming SSR của React 18 xử lý Suspense một cách tự nhiên. - Sinh file HTML tĩnh: Pre-render các route lúc build và serve chúng như file tĩnh, dùng SitemapGenerator của bọn mình để lấy danh sách route.
- Thay đổi tối thiểu với codebase hiện tại: Hầu hết component chạy luôn, không phải sửa.
Phần lõi của việc triển khai nằm trong client/src/generators/GenerateShellSsgFromSitemap.ts. Nó đọc sitemap, render từng route bằng renderToReadableStream của React, rồi ghi HTML ra file tĩnh. Đơn giản đúng kiểu mình thích!
Tốc độ cũng khá ấn tượng. Khoảng 2.800 route được render trong 10 giây. Ngon. Nhanh hơn rõ rệt so với NextJS, Gatsby và Astro. <img alt="Nhật ký console SSG hiển thị thời gian thực thi" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Mình có thể nói mãi về sự đơn giản. Dù nó có thể sẽ không giúp bạn lên chức ở mấy công ty to vì "thiếu độ phức tạp", nhưng code đơn giản thì đẹp, dễ bảo trì và nói chung là tốt hơn rất nhiều cho tốc độ phát triển. Đây là điều mình rất thích ở các nguyên tắc Zen.
Vấn đề với Suspense Boundary
Thế là mình đã có SSG, nội dung xuất hiện trong HTML… nhưng trang lại trắng bóc! Gì thế này?! <img alt="Trang trắng khi dùng SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Hóa ra renderToReadableStream vẫn giữ Suspense boundary, kể cả khi bạn đã gọi await stream.allReady. Mình đoán là vì đây là "stream", được thiết kế để gửi dần từng byte xuống client.
React trả ra cái gì
Khi bạn dùng renderToReadableStream với Suspense, React sẽ xuất HTML kiểu như sau:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
<template id="B:0"> là placeholder đánh dấu nơi nội dung sẽ xuất hiện. <div hidden id="S:0"> chứa nội dung đã render thật sự. B:0 khớp với S:0 thông qua số (đánh số từ 0).
Không có JavaScript, các công cụ tìm kiếm (nhìn bạn đấy, Bing) và LLM gần như chỉ thấy một trang trắng với mỗi template placeholder. Như vậy thì khác gì không có SSG!
Mình không thấy cách sạch sẽ nào để loại bỏ mấy Suspense boundary này, nên giải pháp là viết vài test và một hàm resolveSuspenseBoundaries để hoán đổi chúng. Cách này còn nhanh hơn việc parse HTML và thực thi script bằng thứ gì đó như JSDOM. Và quan trọng hơn, nó là điều kiện bắt buộc cho thứ mình muốn làm: một site dễ đọc cho công cụ tìm kiếm / LLM mà không cần JavaScript, nhưng vẫn hỗ trợ Suspense boundary và hydration trên client.
Test quá trình chuyển đổi
Mình bắt đầu bằng việc viết test cho bước chuyển đổi: lấy ví dụ DOM thực tế giữa hai trạng thái mình có (tắt JavaScript) và mình muốn (bật JavaScript). Mình đưa chúng vào một LLM và nhờ nó tạo bộ test, việc này nó làm khá ổn.
Các test này nằm trong client/src/generators/ssr/renderRoute.test.ts và đảm bảo việc chuyển đổi chạy đúng. Những tình huống được cover gồm:
- Thay thế boundary đơn giản (trang danh sách blog)
- Boundary phức tạp có nội dung nằm giữa template và comment đóng
- Nhiều boundary cùng lúc
- Boundary không có comment đánh dấu
- Các edge case
Kiểu "TDD" này khá hữu ích trong những bài toán kiểu này, nơi bạn có input và output mong đợi rất rõ ràng.
Đừng nhầm với kiểu "TDD mọi thứ vì Robert C. Martin bảo thế" (kiểu đó sẽ kéo chậm tốc độ phát triển của team). Bạn KHÔNG nên dùng TDD cho UI hoặc những phần code thay đổi liên tục!
Giải pháp: resolveSuspenseBoundaries
Khi test đã ổn, mình nhờ LLM viết hàm resolveSuspenseBoundaries. Mình dùng cheerio để tránh độ mong manh của RegEx, dù dùng RegEx ở đây có thể giúp giảm thời gian SSG đi khoảng 40%.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Nhờ đó, thay vì thấy một trang gần như trống trơn, các công cụ tìm kiếm và LLM sẽ thấy một trang đã được render đầy đủ.
Giờ thì bọn mình đã có SSG chạy ngon trong môi trường không có JavaScript!
<img alt="Trang blog Foony với SSG khi tắt JavaScript" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Về lâu dài, rất có thể React sẽ thay đổi format Suspense. Khi mình có giải pháp tốt hơn cho những trang lazy-load (và cần Suspense boundary), mình có thể sẽ bỏ phần code xử lý Suspense này.
Chiến lược Hydration (Cập nhật: Mất 3 ngày + 1 ngày nữa)
Hydration thì vốn dĩ khó rồi. Mình biết điều đó. Nhưng sau một lúc vật lộn, mình cũng khiến nó chạy được!
Tổng thời gian cho hydration: 3 ngày, cộng thêm 1 ngày nữa để thay giải pháp "dehydration".
Phần khó nhất là có được bản hydrate tối thiểu đầu tiên nhưng chạy được. Một khi mình render được "Hello World" cùng với navbar, mình có đủ tự tin rằng ừ, chắc là sẽ không mất đến cả tháng đâu!
<img alt="Hello World của Foony hydrate thành công cùng navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Với bản hydrate tối thiểu đó, mình gặp một thử thách khá đặc biệt: mình muốn có hydration, nhưng đồng thời cũng muốn SEO tốt cho công cụ tìm kiếm và LLM mà developer không phải suy nghĩ về Suspense boundary.
Thử thách
Hydration của React cực kỳ "thẳng ruột ngựa": nếu DOM không đúng như thứ React mong đợi ở lần render đầu tiên, bạn sẽ nhận được một thông báo lỗi vừa dài vừa vô dụng trong console, và React sẽ vứt hết mọi thứ đi rồi render lại từ đầu. Thậm chí không thèm cho bạn một cái diff để biết là sai chỗ nào!
Trong trường hợp của bọn mình, SSG làm mọi thứ tệ hơn ở vài điểm:
- Bọn mình post-process HTML để loại bỏ / resolve mấy artifact Suspense từ React 18 streaming (tốt cho bot).
- Client không phải lúc nào cũng có đúng cùng một data tại thời điểm (t = 0) như khi server render (SSG data, metadata của blog, v.v.).
- i18n của bọn mình mặc định là "lazy", nghĩa là các bản dịch có thể bị thiếu ở lần render đầu tiên trừ khi bạn ghi lại những key được dùng trong SSG và inject chúng trước khi React render.
Cách mình làm (Cách tiếp cận ban đầu: Dehydration)
Ban đầu, mình thử một cách khá "cute": dùng command pattern để ghi lại các lệnh dùng để resolve Suspense boundary trong HTML, rồi trả ngược lại các lệnh đảo chiều để có thể khôi phục HTML về đúng trạng thái React cần cho hydration.
Mình hy vọng có thể gửi ít byte hơn trong index.html với cách này. Nhưng giống như phần lớn những giải pháp quá "thông minh", cách này thất bại vì browser tự ý chỉnh HTML theo những cách rất nhỏ, như thêm/bớt ; hay /, khiến mọi index thay thế đều bị lệch.
Về mặt kỹ thuật thì vẫn có thể xử lý được các thay đổi nhỏ xíu này của browser, nhưng mình chẳng muốn ship một thứ mong manh như vậy.
Thay vì cố "đảo ngược" quá trình xử lý Suspense boundary để quay lại markup streaming của React, mình làm một việc siêu đơn giản:
Gói luôn HTML gốc, chưa resolve, vào một thẻ <script type="text">.
Cách "dehydration" này chạy được, nhưng mình đã tốn thêm một ngày nữa để thay nó bằng giải pháp tốt hơn.
Giải pháp tốt hơn: Thay Suspense boundary trên "critical path"
Sau khi có bản triển khai đầu tiên, mình vẫn gặp vài vấn đề với Suspense boundary. Đến lúc đó mình mới nhận ra có một cách sạch hơn, gọn hơn, đơn giản hơn. Mình thay hẳn cách dehydration bằng critical path Suspense boundary replacement, cách này:
- Load trước critical path trước khi hydrate: Những component được preload trong lúc SSR sẽ được đánh dấu và preload trên client trước khi gọi
hydrateRoot
- Dễ bảo trì hơn: Không cần đụng đến internals của React hay phân tích AST (cách dehydration cần parse và khôi phục HTML)
- Gửi ít byte hơn: Không còn phải gói nguyên response SSR gốc của React trong một thẻ script
- Tránh khả năng bị "flash": Không cần dehydrate / rehydrate HTML, loại bỏ nguy cơ chớp nội dung khó chịu
Cách triển khai sẽ theo dõi các component lazy được preload trong lúc SSR (thông qua SSRLazyComponentTracker), nhúng đường dẫn import của chúng vào hydration data, và preload chúng đồng bộ trước khi hydrate. Các component critical path sẽ render trực tiếp mà không cần Suspense boundary, nên output khớp 100% với SSR.
Với những phần còn lại, bọn mình để lần render đầu tiên trên client hành xử như SSR/SSG. Tức là dùng cùng input, và khiến input đó sẵn sàng một cách đồng bộ trước khi gọi hydrateRoot. Điều này thực hiện bằng cách bundle thông qua "ssg-data" của bọn mình.
Cụ thể, bọn mình điều chỉnh như sau:
Bundle input SSR vào một thẻ script text duy nhất
- Trong lúc SSG, bọn mình inject một thẻ
<script type="text/foony-ssg" id="foony-ssg-data">...</script> ngay trước entrypoint module của Vite.
- Thẻ script này chứa:
html: phần HTML đã được resolve mà bọn mình thực sự ship trong file tĩnh
ssgData: bản serialize SSGData được dùng bởi wrapper SSR. Mình định sau này sẽ chỉnh thành Proxy hoặc gì đó để chỉ include những data thực sự được truy cập.
translationData: các cặp key-value bản dịch được đụng đến trong SSR
Inject các input đó ngay trước khi hydrate
- Trong
main.tsx, bọn mình đồng bộ:
- đặt
#root.innerHTML thành HTML đã resolve được serialize (để DOM trông đúng như cái hydration nhìn thấy)
- bọc app trong
SSGDataProvider để component có cùng SSGData ngay ở lần render đầu tiên
Khiến i18n "instant" bằng cách inject giá trị dịch
- Bọn mình ghi nhận lại object bản dịch thực sự được truy cập trong lúc SSR và ship chúng trong script SSG.
- Trên client, bọn mình inject thẳng chúng vào cache của
LocaleQueryer thông qua một method riêng LocaleQueryer.inject(), nên bản dịch có sẵn ngay lập tức.
Và như vậy, lần render đầu tiên sẽ có đúng data mà SSR đã dùng!
Hook useIsSSRMode() đã được implement trong client/src/generators/ssr/isSSRMode.ts:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Hook này trả về true trong SSR và ở lần render đầu tiên trên client (trong lúc hydration), sau đó chuyển sang false sau khi mount. Các component như UserBanner, Navbar và Dialog đã dùng hook này để tránh hydration mismatch.
- Patch React để có diff dễ hiểu hơn
Mình đã hy vọng chỉ cần dùng hydration-overlay. Nhưng project này không được maintain tích cực, chỉ hỗ trợ tới React 18 và chưa sẵn sàng cho production. Thế nên mình nhờ LLM clone repo này để tham khảo, rồi nó tạo ra một bản hydration overlay tối giản chỉ trong vài phút. Mình không cần thứ gì màu mè, chỉ cần một overlay hiện lên trong lúc phát triển để nhìn xem chuyện gì đang lệch.
Overlay mới này cực kỳ cơ bản, nên phần diff chưa thật sự hoàn hảo. React loại bỏ comment, thêm ; sau thuộc tính style, chỉnh whitespace, và vài thứ nhỏ khác mà overlay của bọn mình chưa xử lý (ít nhất là chưa). Overlay này cũng bao gồm cả HTML comment mà React bỏ qua trong quá trình hydration.
<img alt="Hydration overlay mới của bọn mình" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Nhưng như vậy cũng đủ để mình biết cần sửa chỗ nào.
<img alt="diff giữa SSG và lần render đầu tiên trên client phục vụ hydration của React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
Các con số
Để bạn hình dung rõ hơn phần việc phải làm:
- 2 ngày làm việc (từ lúc bắt đầu đến lúc SSG chạy được). Thực tế tầm hơn 24 tiếng trong lúc đang nghỉ lễ.
- 4 ngày để khiến hydration hoạt động ổn, không còn đua bất đồng bộ của i18n hay
useMediaQuery phá hỏng mọi thứ.
- 1 ngày thêm để thay cách dehydration bằng critical path Suspense boundary replacement (đơn giản hơn, ít byte hơn, không lo bị flash).
- ~200 dòng code core cho SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 dòng xử lý Suspense boundary (
resolveSuspenseBoundaries trong renderRoute.tsx) - Lưu ý: Sau đó đã được thay bằng cách critical path
- ~50 dòng utility SSR (
isSSRMode.ts)
- ~100 dòng test (
renderRoute.test.ts)
- ~150 dòng polyfill cho SSR (
setupSSREnvironment)
- Thay đổi tối thiểu ở các component hiện có (chủ yếu thêm check
useIsSSRMode())
Giải pháp này nhẹ nhàng, dễ bảo trì. Không cần migrate sang framework nào khác và vẫn chạy ngon với SPA React hiện tại của bọn mình.
Những điều rút ra
Đôi khi giải pháp "đặt may" lại là tốt nhất
Không phải bài toán nào cũng cần framework. Với Foony, một giải pháp SSG nhỏ gọn, viết riêng chính là lựa chọn hợp lý. Nó:
- Nhẹ: Không có dependency nặng hay overhead từ framework
- Dễ bảo trì: Code đơn giản, bọn mình hiểu rõ
- Linh hoạt: Dễ chỉnh sửa và mở rộng khi cần
- Tương thích: Chạy được với SPA React hiện tại mà không cần migrate
Streaming SSR của React cũng "khó ở"
renderToReadableStream của React rất ổn khi làm việc với Suspense, nhưng cũng có quirks riêng. Kể cả khi dùng await stream.allReady, bạn vẫn nhận về output có Suspense boundary. Đây không phải bug, mà là thiết kế cho streaming. Nhưng với SSG, bọn mình cần HTML đã resolve hoàn toàn. Cảm giác như team React đã bỏ sót một trường hợp sử dụng khá quan trọng mà lẽ ra có thể xử lý gọn hơn.
Giải pháp của mình là post-process HTML và resolve các boundary đó. Nhìn thì không đẹp lắm, nhưng nó nhanh và đủ linh hoạt cho nhu cầu của mình.
TDD có thể rất hữu ích khi làm việc với LLM
Việc transform HTML khá dễ gây lỗi. Chỉ một bug nhỏ thôi là có thể phá toàn bộ output SSG và phá luôn trải nghiệm người dùng. Mình nhờ LLM viết giúp bộ test đầy đủ (dĩ nhiên với input mình cung cấp) để đảm bảo quá trình transform chạy đúng.
Kết luận
SSG giờ đã hoạt động cho Foony. Trang được render đầy đủ cho công cụ tìm kiếm và LLM, và giải pháp thì nhẹ, dễ bảo trì. Hydration cho các route SSG mất nhiều thời gian hơn mình tưởng (3 ngày), và mình còn tốn thêm một ngày để thay cách dehydration ban đầu bằng critical path Suspense boundary replacement. Cách mới đơn giản hơn để bảo trì, gửi ít byte hơn và tránh được nguy cơ flash do dehydrate/rehydrate HTML.
Đến giờ mình vẫn hơi sốc là mất đúng 2 ngày để triển khai xong một giải pháp SSG "đặt may". Nhưng đôi khi giải pháp đúng lại chính là giải pháp đơn giản nhất.
Trong tương lai, mình sẽ tiếp tục hoàn thiện phần khớp hydratrion và có thể sẽ patch React để debug tiện hơn. Còn hiện tại, Foony đã có SSG chạy ngon lành. Mình sẽ theo dõi Google Search Console và Bing Webmaster Tools trong vài tuần tới để xem chuyện này ảnh hưởng thế nào đến SEO của bọn mình.