---
url: https://docs.nemo12.com/architecture/sdd-029-public-site-ia/prerender.md
description: >-
  Dữ liệu API dựng sẵn vào HTML, accessibility, prerender bằng React, rà SEO
  trang cũ, cụm bài viết SEO trong D1.
---

# SDD-029 · Dựng sẵn HTML, accessibility và cụm bài viết SEO

Một phần của [SDD-029](./index.md). Dữ liệu API dựng sẵn vào HTML, accessibility, prerender bằng React, rà SEO trang cũ, cụm bài viết SEO trong D1.

## Lịch sử quyết định

### Dữ liệu API công khai dựng sẵn vào HTML (SRC-1170, 30.09.2026)

Sau SRC-1154, rà SEO trên production còn 11 trang dưới 150 chữ trong HTML tĩnh: các trang đọc API
(đội ngũ mentor, câu chuyện, cộng đồng) dựng sẵn lúc build khi chưa có dữ liệu, nên chỉ có khung
giữ chỗ và bot không đọc được tên hay giới thiệu của ai.

* `apps/web/src/bootData.ts`: kho dữ liệu dựng sẵn, khoá là đường dẫn API, giá trị là phong bì JSON.
  Lúc build `apps/web/scripts/prerender.mjs` gọi 10 API công khai (mentors, albums, stories theo ba
  nhóm người đọc, testimonials) và nạp kho; mỗi trang chỉ nhúng đúng những khoá nó đã đọc, vào
  `<script type="application/json" id="n12-boot">`. Trên trình duyệt kho đọc khối ấy, nên lần render
  đầu của React trùng bản dựng sẵn; component vẫn gọi API để lấy bản mới nhất.
* Component đọc qua `publicApi.peekMentors/peekAlbums/peekStories/peekTestimonials` ở state đầu
  (`Team.tsx`, `IeltsMentorsPage.tsx`, `StoriesPages.tsx`).
* KHÔNG nạp bảng giá: giá đổi theo ngày (mức tháng 9 hết hạn 01.10.2026), nhúng lúc build là có thể
  hiện giá đã hết hạn. API hỏng lúc build thì trang dựng khung giữ chỗ như trước.
* `Stagger` bắt đầu ở trạng thái đã hiện trong lượt tải đầu có bản dựng sẵn: bắt đầu ở "an" thì thẻ
  con đứng ở opacity 0, chữ có trong HTML mà màn trống.
* `/mentors`: h2 ẩn "Đội ngũ mentor" trước danh sách (tên từng người là h3; heading-order).
* `EssayPriceCard` (trang học phí Essay mới, chép khuôn Grammar CŨ) mang lại đúng lỗi SRC-1154 đã sửa
  ở Grammar: đang tải thì nói "đang được cập nhật", thẻ giá về đẩy cả trang (CLS 0,126). Sửa như
  Grammar: `null` là đang tải, khung giữ chỗ cao bằng thẻ thật (272 / 239 px). CLS 0.
* Đo ở máy: `/community` 126 -> 988 chữ, `/stories` 141 -> 379, `/mentors` 120 -> 185; bản dựng
  sẵn và bản React của các trang này trùng nhau, CLS 0.

### Accessibility trang công khai, và dựng sẵn các trang cũ (SRC-1163, 30.09.2026)

Đo trên production sau SRC-1154 (Lighthouse mobile, Accessibility): trang chủ 92, `/ielts/band` 92,
học phí Grammar 94, `/parents` 96. Sửa theo từng loại lỗi, ở chỗ gốc chứ không từng trang:

* **color-contrast**: coral `--accent` chỉ 3,29:1 trên nền trắng; DS-001 dành nó cho viền, mũi tên,
  chấm, và dành `--accent-deep` (4,80:1) cho chữ. Trang công khai dùng `text-accent` cho chữ hơn
  trăm chỗ, nên sửa một lần ở tầng theme (`apps/web/src/index.css`): trên nền sáng `text-accent` lấy
  `--accent-deep`, trên `.n12-band-dark` giữ coral gốc. Link trong thanh cookie (nền xám nhạt,
  4,35:1) dùng `--accent-ink`. Hai nút viền ở chân trang nền tối ("Chuyển sang", "Chọn vai") mang
  chữ màu của nền sáng (1,95:1): thêm `text-ocean-900` như các chữ khác ở chân trang.
* **aria-allowed-attr**: thẻ chọn vai của `RoleGate` là LINK mà mang `aria-pressed`; đổi sang
  `aria-current`.
* **label-content-name-mismatch**: nút menu hiện chữ "Menu" nhưng `aria-label` là "Mở điều hướng";
  bỏ `aria-label`, chữ trên nút là tên.
* **heading-order**: `PricingList` chế độ `bare` (bảng giá đứng thẳng dưới h1: Grammar, Essay) dùng
  h2 cho nhóm thay vì h3.
* **link-name**: mục "Đọc tiếp" bị gập đặt `hidden` lên thẻ BÊN TRONG link, để lại link rỗng mà phím
  Tab vẫn dừng vào; nay `hidden` bọc cả mục.
* **Trang cũ ngoài bảng route** (`LEGACY_META`: `/mentors`, `/community`, `/privacy`,
  `/specialist-schools`, năm trang linh vật) nay cũng dựng sẵn bằng React như trang app; `/login`
  (noindex) giữ bản tối thiểu. `/mentors` từ 15 chữ lên ~120 chữ trong HTML tĩnh.

Đo ở máy: Accessibility 100 trên trang chủ, `/parents`, `/sat`, `/ielts/band`, `/mentors`, học phí
Grammar, `/teachers/celta`.

Độ dài tiêu đề và mô tả của TOÀN BỘ sitemap (bổ sung SRC-1150): quét 436 trang không có
`canonical` (trừ cụm `/{service}/articles/**` sinh từ D1), 119 trang lệch chuẩn (tiêu đề 30 tới 60
ký tự gồm hậu tố, mô tả 110 tới 160), nay 0. Sửa tại nguồn: `apps/web/src/site/routes.ts` (trang
viết tay, và các nhóm sinh hàng loạt qua `fitTitle`/`fitDescription`) và các file
`apps/web/content/seo/{ielts,celta,tesol,tefl}/*`. Trang nội dung dùng tiêu đề làm h1, nên vài h1
đổi theo; không slug nào đổi.

### Trang app dựng sẵn bằng chính React (SRC-1151 trang chủ, SRC-1154 mọi route, 30.09.2026)

Đo trước khi sửa (production, Lighthouse mobile): trang chủ 70 (LCP 6,1 s, phần tử LCP là chữ của
THANH COOKIE, thứ hiện sau cùng), `/parents` 77, `/students` 73, `/teachers` 75,
`/students/ielts/tuition` 71. Bản tĩnh của trang app là phần chữ tóm tắt cho bot, khác hẳn bố cục
React, nên không áp được khung tĩnh chép tay của trang nội dung (SRC-1149).

* **Render thẳng `<App />` cho từng đường dẫn lúc build** bằng `react-dom/static`
  (`apps/web/src/ssrApp.tsx`), thứ chờ được các chunk `lazy()`. Trang chủ (SRC-1151) làm trước với
  `RoleGate` + `CookieNotice`, rồi gộp vào cùng đường. `apps/web/scripts/prerender.mjs` đặt kết
  quả vào `#n12-static` và dùng lại cơ chế đổi bản của `apps/web/src/staticHandoff.ts`. Render hỏng
  thì trang lùi về bản cũ (chữ tóm tắt, CSS chặn vẽ), không đánh rơi trang.
* **Node không có DOM**: một `window` tối thiểu cho đúng phần code chạy lúc RENDER (đường dẫn,
  `localStorage` rỗng, `matchMedia`); effect không chạy khi render phía server. React để ngoài
  bundle, `mainFields` ưu tiên bản ESM của các gói (bản CommonJS của `lucide-react` làm hỏng render).
* **CSS tối thiểu riêng từng trang**, tính từ lớp và thẻ của chính markup ấy.
* **Bản dựng là trạng thái NGƯỜI MỚI.** Script đồng bộ trong `<head>` gắn `n12-has-role` /
  `n12-has-consent` lên `<html>` trước lần vẽ đầu; CSS nhúng ẩn thanh cookie với người đã chọn, và
  màn chọn vai ở trang chủ với người đã chọn vai.
* `Appear`/`FadeIn` không chạy trong bản dựng sẵn và lượt tải đầu có bản tĩnh.
* **SEO kèm theo**: bot đọc toàn bộ nội dung thật của trang app thay vì phần tóm tắt (trước đó 89
  trang có dưới 150 chữ trong HTML tĩnh).
* Đo ở máy: trang chủ 100, `/parents` 97, `/students` 97, `/students/university/ielts` 95,
  `/teachers` 97, `/students/ielts/tuition` 100, `/about` 100, `/education` 100; CLS 0.

### Rà SEO các trang cũ (SRC-1150, 30.09.2026)

Rà 461 URL trong sáu sitemap cũ trên production (tiêu đề, mô tả, canonical, h1, noindex, og:image,
số chữ trong HTML tĩnh). Bốn nhóm lỗi đã sửa:

* **Trang trùng lặp: một bản chính mỗi nhóm.** 33 cặp `/students/...` và `/parents/...` cùng tiêu đề,
  cùng mô tả, nội dung lệch chưa tới 1%, cộng `/ielts/tuition` trùng hai bản theo vai; mỗi bản tự
  khai canonical về chính nó. `withCanonicals` trong `apps/web/src/site/routes.ts` chọn bản chính
  cho mỗi nhóm cùng tiêu đề (ngắn nhất sau khi bỏ tiền tố vai, bằng nhau thì bản học sinh) và điền
  `canonical` cho các bản còn lại. Prerender và `useSeo` khai canonical theo trường đó; sitemap chỉ
  liệt kê bản chính. Các bản phụ vẫn mở được bình thường.
* **Mười trang cũ tự khai là trang chủ.** `/mentors`, `/community`, `/privacy`, `/specialist-schools`,
  `/login` và năm trang linh vật nằm trong sitemap nhưng không được prerender, nên worker trả vỏ SPA
  mang tiêu đề và canonical của trang chủ. Nay `LEGACY_META` (routes.ts) giữ tiêu đề, mô tả, đoạn
  mở đầu cho từng trang, lấy từ đúng chữ đang hiện trên trang; prerender dựng mỗi trang một file;
  `App.tsx` đặt tiêu đề từ cùng bảng ấy. `/login` mang `noindex` và rời sitemap.
* **Độ dài tiêu đề và mô tả.** Khoảng 80 trang ngoài chuẩn (tiêu đề 30 tới 60 ký tự gồm hậu tố,
  mô tả 110 tới 160) được viết lại tại nguồn; nhóm sinh hàng loạt đi qua `fitTitle`/`fitDescription`
  trong routes.ts, chọn câu ứng viên đầu tiên lọt khung từ chính chữ của trang.
* **Khung tĩnh của trang nội dung khớp cả thân bài** (bổ sung SRC-1149): thân bài dựng đúng markup
  `Prose`/`section`/`Bullets`, có chỗ cho khối video dưới phần mở đầu, và `FadeIn` không chạy hiệu
  ứng trong lượt tải đầu có khung tĩnh (`isStaticBoot` trong `apps/web/src/staticHandoff.ts`), vì
  cho chữ đang hiện mờ đi rồi hiện lại là một lần chớp, và Chrome tính lần hiện lại là ứng viên LCP.
  Đo: `/ielts/reading/true-false-not-given` từ 83 lên 100; 224/224 trang lệch 0 px ở cả h1 lẫn h2
  đầu thân bài.

Còn lại, chưa sửa: 89 trang app có dưới 150 chữ trong HTML tĩnh (bản cho bot của trang app là phần
chữ tóm tắt, React dựng phần còn lại); và ~177 trang ngoài danh sách đã rà vẫn lệch chuẩn độ dài.

### Trang nội dung vẽ từ bản tĩnh, React thay vào sau (SRC-1149, 30.09.2026)

Chủ dự án: nâng `/sat` lên PageSpeed mobile >= 95. Đo trước khi sửa: Performance 81, FCP 2,4 s, LCP
4,2 s. Hai nguyên nhân, cả hai nằm ở cách bản prerender nhường chỗ cho React:

1. **React xoá chữ tĩnh trước khi nó kịp hiện.** Chữ tĩnh nằm trong `#root`, gói JS về kịp trước
   lần vẽ đầu, và `createRoot().render()` thay sạch `#root`; nội dung chỉ hiện sau khi thêm một
   chunk lazy về. LCP đứng sau cả chuỗi HTML, gói ~200 KB, chunk trang.
2. **Lần vẽ đầu chờ CSS và JS.** File CSS 97 KB (21 KB nén) chặn vẽ, và mô phỏng mạng chậm của
   Lighthouse tính mọi request bắt đầu trước lần vẽ đầu (kể cả gói JS có `modulepreload`) vào FCP.

Cách làm, CHỈ cho 224 trang của các họ trang nội dung (`apps/web/content/seo`):

* **Khung tĩnh ngoài `#root`** (`staticShell` trong `apps/web/scripts/prerender.mjs`): phần mở đầu dùng đúng
  lớp của khuôn hero trong `apps/web/src/site/Kit.tsx`, breadcrumb dựng đúng markup của `Crumbs`, thanh nav giữ
  chỗ cao bằng `SiteNav`. Đo trên cả 224 trang: tiêu đề h1 của bản tĩnh và bản React lệch 0 px.
* **React vẽ vào `#root` đã tách khỏi tài liệu**, rồi được gắn thay chỗ khung tĩnh trong cùng một
  khung hình khi nội dung thật đã commit (`apps/web/src/staticHandoff.ts`, gọi từ `StaticHandoff` trong
  `Suspense` của `apps/web/src/App.tsx`). Không dùng `hidden`: Chrome tính phần tử có sẵn khung 0x0 mà hiện ra
  là một lần dịch chuyển (đo được CLS 0,237); phần tử mới chèn vào thì không.
* **CSS tối thiểu nhúng sẵn** (~14 KB, 4 KB nén): Tailwind biên dịch `apps/web/src/index.css` với đúng các
  lớp khung tĩnh dùng, rồi bỏ mọi luật có selector nhắc tới lớp hay thẻ không có trong khung.
* **CSS đầy đủ và gói JS chỉ bắt đầu tải sau khung hình đầu tiên** (`withStaticHead`); bước đổi
  sang bản React chờ CSS đầy đủ xong, để không lộ trang chưa có kiểu.
* **Vỏ SPA sạch** `spa-shell.html` cho đường dẫn động (`/mentors/{slug}`...): `index.html` nay là
  prerender trang chủ, dùng nó làm vỏ là cho các trang ấy chớp nội dung trang chủ.
* **Trang app (`ROUTES`) giữ cách cũ.** Bản tĩnh của chúng là phần chữ tóm tắt cho bot, KHÁC bố cục
  và cả tiêu đề của bản React; giữ nó trên màn cho tới khi React xong là cho người đọc thấy một
  trang khác rồi đổi. Muốn áp cho nhóm này thì bản tĩnh phải dựng đúng bố cục của từng trang trước.

Kết quả đo ở máy (Lighthouse mobile): trang nội dung Performance 100, FCP ~0,9 s, LCP ~1,1 s.
`/sat` ở máy còn CLS do khối video tự ẩn khi `api.nemo12.com` chặn CORS từ `127.0.0.1`; trên
tên miền thật video tải được và CLS bằng 0.

### Cụm bài viết SEO theo dịch vụ, nội dung trong D1 (SRC-1132, 29.09.2026)

Chủ dự án: làm SEO cho NEMO IELTS, NEMO SAT, NEMO SPEAK, NEMO GRAMMAR; mỗi dịch vụ 100 bài chia 5
category, mỗi cụm xoáy vào một Job To Be Done của một nhóm audience, một dòng đi theo từng bước
hành trình; ảnh bìa và hình minh hoạ sinh bằng thuật toán dạng SVG, có bản mobile và desktop;
toàn bộ nội dung nằm trong D1, mỗi bài một URL; PageSpeed mobile trên 95; chuyển thành skill.

* **URL** (quyết một lần, không đổi):

  | Tầng | Đường dẫn |
  | --- | --- |
  | Hub dịch vụ | `/{service}/articles` |
  | Category | `/{service}/articles/{category}` |
  | Bài | `/{service}/articles/{category}/{slug}` |
  | RSS đủ nội dung (Substack nhập được) | `/{service}/articles/feed.xml` |
  | Sitemap con | `/sitemap-articles-{service}.xml` (sitemap index trỏ tới) |

  `{service}` ∈ `ielts, sat, speaking, grammar`, trùng đường dẫn sản phẩm trên learn. Slug tiếng Anh
  (SRC-604), 2 tới 8 từ, mở đầu bằng keyword, không năm, không số thứ tự. Category nằm trong URL nên
  bài không đổi category sau khi xuất bản.
* **Năm category mỗi dịch vụ**: bốn `jtbd` (mỗi cái một job của một audience, luôn có một cho phụ
  huynh) và một `journey` (20 bước theo thứ tự). Mỗi bài `jtbd` mang một góc nhìn: job-map, pain,
  gain, fear, need, solution, myth, mistake, compare, checklist, story, metric, decision.
* **Nội dung sống trong D1** (migration 0313: `article_services`, `article_categories`, `articles`).
  Markdown ở `apps/web/content/articles/` là bản SOẠN; `apps/web/scripts/articles/seed.mjs` sinh
  `scripts/seed-articles-{service}.sql` (idempotent, bài gỡ khỏi repo thành `draft`), nạp qua
  workflow `seed-data`. `body_html` dựng sẵn lúc seed, nên worker không mang bộ đọc Markdown.
* **Worker `nemo12-web` dựng trang từ D1** (`apps/web/worker/articles/`), cache edge 1 giờ; URL không
  có dòng trong D1 trả 404 thật. Trang KHÔNG có JavaScript, CSS Tailwind (token DS) nhúng ~14 KB.
  Lý do: trang SPA `/sat` đo được 68 điểm Performance mobile (LCP 6,4 s) vì gói React; trang bài
  dựng thử đo 100/100/100/100.
* **Hình**: `worker/articles/art.ts`, hàm thuần. Bìa 1200x630 (desktop, chia sẻ) và 800x800
  (mobile); 8 dạng hình (steps, bars, compare, cycle, matrix, ladder, funnel, timeline), mỗi hình
  hai bản 800 và 480, chọn bằng `<picture>` ở mốc 640px; `cycle` và `matrix` đổi hẳn sang bố cục dọc
  ở bản mobile.
* **Cổng** `scripts/check-articles.mjs` (trong `check:code`): slug, độ dài title/description, trùng
  title/description, >= 1100 từ, >= 2 hình vẽ được ở cả hai bản, >= 3 link nội bộ không gãy, không
  em/en dash, nhắc Nemo <= 5, trùng cụm 5 từ <= 12%, khoá lạ trong dữ liệu hình, seed khớp Markdown.
* **Skills**: `/article-plan`, `/article-write`, `/article-publish`.
* **Khác với SRC-1103**: SRC-1103 là vài bài dài viết tay trong mảng TS, đặt dưới
  `/{vai}/university/{môn}/articles`; SRC-1132 là cụm SEO sinh theo kế hoạch, sống trong D1. Hai
  không gian URL không giao nhau.
* **Ảnh chia sẻ PNG** (`og:image`, `twitter:image`, JSON-LD, RSS): mạng xã hội không hiện SVG, nên
  worker render chính SVG bìa ra `cover.png` 1200x630 bằng `@resvg/resvg-wasm` (`worker/articles/png.ts`),
  font nhúng Be Vietnam Pro (OFL-1.1, 400 + 800) vì worker không có font hệ thống; cache edge như mọi
  ảnh khác. Ảnh hiển thị trong trang vẫn là SVG (nhẹ hơn). Worker nén ~1,2 MB.
