2026-03-207 phút đọcVI
Sitemap & Robots trong Static Export
Mở đầu
Google không tự biết tất cả URL trên website anh. Googlebot crawl theo links, nhưng nếu trang nào đó không được link từ đâu cả, nó sẽ bị "mồ côi" — không bao giờ được index. Sitemap giải quyết vấn đề này: nó là một file XML liệt kê TẤT CẢ URLs mà anh muốn Google biết.
Robots.txt thì ngược lại — nó nói cho bots biết: "Đừng crawl phần này." Hai file này hoạt động song song: sitemap nói "đây là nội dung quan trọng", robots nói "đây là nội dung không cần crawl."
leduykhuong.com dùng static export (output: "export") — toàn bộ site được build thành HTML tĩnh, deploy lên Cloudflare Pages. Điều này tạo ra thách thức riêng cho sitemap generation mà bài này sẽ phân tích.
Mục tiêu: Hiểu cách Next.js generate sitemap và robots.txt, pattern withLocales cho multilingual sites, và tại sao export const dynamic = "force-static" là bắt buộc trong static export.
Robots.txt — File đơn giản nhất trong SEO
File app/robots.ts trên leduykhuong.com chỉ 13 dòng:
// app/robots.ts
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const BASE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://leduykhuong.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/_next/"] },
sitemap: `${BASE_URL}/sitemap.xml`,
};
}Next.js render thành robots.txt:
User-agent: *
Allow: /
Disallow: /_next/
Sitemap: https://leduykhuong.com/sitemap.xml
Phân tích:
userAgent: "*"— Áp dụng cho TẤT CẢ bots (Googlebot, Bingbot, social crawlers...).allow: "/"— Cho phép crawl toàn bộ site.disallow: ["/_next/"]— Chặn crawl thư mục_next/(chứa JS bundles, CSS, internal Next.js files). Không có lý do gì để index những file này.sitemap— Chỉ dẫn bots tìm sitemap tại URL này. Google sẽ fetch và parse sitemap từ đây.
export const dynamic = "force-static" — Đây là dòng quan trọng nhất. Trong static export, Next.js yêu cầu Route Handlers phải declare rõ là static. Không có dòng này → build error vì Next.js mặc định Route Handlers là dynamic (server-side).
Sitemap — Bản đồ toàn bộ website
Sitemap XML liệt kê mọi URL cần index, kèm metadata:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://leduykhuong.com/vi</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://leduykhuong.com/en</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<!-- ...hundreds more URLs... -->
</urlset>Mỗi <url> entry chứa:
| Field | Ý nghĩa | Google dùng? |
|---|---|---|
loc | URL absolute | Có — bắt buộc |
lastmod | Lần cuối cập nhật | Có — dùng để ưu tiên re-crawl |
changefreq | Tần suất thay đổi | Không — Google bỏ qua field này |
priority | Độ quan trọng (0.0-1.0) | Không — Google bỏ qua field này |
Thực tế: Google chỉ dùng loc và lastmod. Các field changefreq và priority là legacy — Google tuyên bố bỏ qua chúng. Tuy nhiên, khai báo vẫn không hại gì và giúp anh tổ chức suy nghĩ về cấu trúc site.
Pattern withLocales — DRY cho Multilingual Sites
leduykhuong.com có 2 ngôn ngữ (vi, en). Mỗi page cần 2 entries trong sitemap. Helper function withLocales giải quyết lặp code:
// app/sitemap.ts
const LOCALES = ["vi", "en"] as const;
function withLocales(
path: string,
opts: {
lastModified?: Date;
changeFrequency?: MetadataRoute.Sitemap[number]["changeFrequency"];
priority?: number;
} = {}
): MetadataRoute.Sitemap {
const {
lastModified = new Date(),
changeFrequency = "monthly",
priority = 0.5,
} = opts;
return LOCALES.map((locale) => ({
url: `${BASE_URL}/${locale}${path}`,
lastModified,
changeFrequency,
priority,
}));
}Cách hoạt động: Nhận 1 path → trả về array 2 entries (vi + en). Default values cho changeFrequency và priority giảm boilerplate.
Sử dụng:
// 1 dòng = 2 sitemap entries
withLocales("", { changeFrequency: "weekly", priority: 1.0 })
// → [{ url: ".../vi", ... }, { url: ".../en", ... }]
withLocales("/about", { changeFrequency: "monthly", priority: 0.8 })
// → [{ url: ".../vi/about", ... }, { url: ".../en/about", ... }]Tại sao quan trọng: Nếu không có helper, mỗi static page cần 2 entries viết tay. Với 5 static pages = 10 entries. Thêm 125 blog posts = 260 entries. Helper giảm code từ 260 dòng xuống ~30 dòng.
Sitemap Generation — Toàn bộ flow
Function sitemap() build toàn bộ URL map:
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getPosts();
const topics = getAllTopics();
const series = getAllSeries();
const pillarSlugs = Object.keys(PILLARS) as PillarSlug[];
// 1. Static pages — homepage, about, blog landing, etc.
const staticPages: MetadataRoute.Sitemap = [
...withLocales("", { changeFrequency: "weekly", priority: 1.0 }),
...withLocales("/about", { changeFrequency: "monthly", priority: 0.8 }),
...withLocales("/blog", { changeFrequency: "weekly", priority: 0.9 }),
...withLocales("/pillars", { changeFrequency: "weekly", priority: 0.6 }),
...withLocales("/topics", { changeFrequency: "weekly", priority: 0.6 }),
];
// 2. Blog posts — dynamic from MDX files
const postPages: MetadataRoute.Sitemap = posts.flatMap((p) => {
const date = p.date ? new Date(p.date) : new Date();
const lastModified = Number.isNaN(date.getTime()) ? new Date() : date;
return withLocales(`/blog/${p.slug}`, {
lastModified,
changeFrequency: "monthly",
priority: 0.7,
});
});
// 3. Pillar pages, Topic pages, Series pages
const pillarPages = pillarSlugs.flatMap((slug) =>
withLocales(`/pillars/${slug}`, { changeFrequency: "weekly", priority: 0.6 })
);
const topicPages = topics.flatMap(({ slug }) =>
withLocales(`/topics/${slug}`, { changeFrequency: "weekly", priority: 0.6 })
);
const seriesPages = series.flatMap(({ slug }) =>
withLocales(`/series/${slug}`, { changeFrequency: "monthly", priority: 0.6 })
);
// 4. Combine all
return [...staticPages, ...postPages, ...pillarPages, ...topicPages, ...seriesPages];
}Cấu trúc ưu tiên:
| Loại page | Priority | Lý do |
|---|---|---|
| Homepage | 1.0 | Entry point, highest importance |
| Blog landing | 0.9 | Content hub |
| About | 0.8 | Brand credibility |
| Blog posts | 0.7 | Core content |
| Topics, Pillars, Series | 0.6 | Navigation/taxonomy |
Error handling cho dates: Dòng Number.isNaN(date.getTime()) ? new Date() : date xử lý trường hợp MDX frontmatter có date format sai — fallback về ngày hiện tại thay vì crash build.
Static Export — force-static là bắt buộc
Khi dùng output: "export" trong next.config.ts, Next.js build toàn bộ site thành static HTML. Các Route Handlers (sitemap, robots) phải khai báo:
export const dynamic = "force-static";Tại sao? Next.js mặc định Route Handlers là dynamic — chúng chạy trên server mỗi request. Trong static export, không có server. Nếu không khai báo force-static:
Error: Page "/sitemap.xml" is missing "export const dynamic = 'force-static'"
Build output: Sau npm run build, check thư mục out/:
out/
├── sitemap.xml ← Generated from app/sitemap.ts
├── robots.txt ← Generated from app/robots.ts
├── index.html ← Homepage
├── vi/
│ ├── index.html
│ ├── blog/
│ │ ├── learning-in-public.html
│ │ └── ...
│ └── ...
└── en/
└── ...
sitemap.xml và robots.txt nằm ở root — đúng vị trí mà Google tìm kiếm (convention: https://domain.com/sitemap.xml).
Google Search Console — Submit Sitemap
Sau khi deploy, submit sitemap qua Google Search Console (GSC):
- Vào GSC → chọn property
leduykhuong.com - Menu trái → "Sitemaps"
- Nhập
sitemap.xml→ Submit
GSC sẽ:
- Fetch sitemap định kỳ
- Báo cáo: bao nhiêu URLs discovered, bao nhiêu indexed
- Cảnh báo nếu sitemap có errors
Lưu ý: Submit sitemap KHÔNG đảm bảo Google sẽ index tất cả URLs. Google có thuật toán riêng để quyết định page nào xứng đáng index (dựa trên content quality, backlinks, crawl budget...). Sitemap chỉ là "gợi ý."
Thực hành
Bài tập 1: Đếm entries trong sitemap
cd ACE-component/ACE-leduykhuong-site && npm run build
# Đếm số URL entries
grep -c '<loc>' out/sitemap.xml
# Xem 10 entries đầu
grep '<loc>' out/sitemap.xml | head -10Câu hỏi: Tổng cộng bao nhiêu URLs? So sánh với số blog posts × 2 locales + static pages × 2.
Bài tập 2: Verify robots.txt
cat out/robots.txtCâu hỏi: Sitemap URL trong robots.txt có khớp với file sitemap.xml thực tế không?
Bài tập 3: Thử bỏ force-static
Tạm comment dòng export const dynamic = "force-static" trong app/sitemap.ts, rồi chạy npm run build. Quan sát error message — nó giải thích rõ tại sao force-static cần thiết trong static export.
Tóm tắt
- robots.txt — Nói bots: crawl gì, không crawl gì, sitemap ở đâu. leduykhuong.com chặn
/_next/(internal files). - sitemap.xml — Liệt kê tất cả URLs cần index. Google chỉ dùng
locvàlastmod, bỏ quachangefreqvàpriority. withLocaleshelper — DRY pattern cho multilingual: 1 path → 2 entries (vi + en).force-static— Bắt buộc cho Route Handlers trong static export (output: "export").- Priority hierarchy — Homepage (1.0) > Blog landing (0.9) > About (0.8) > Posts (0.7) > Taxonomy (0.6).
- Submit sitemap qua GSC — nhưng Google tự quyết định index hay không.
Bài tiếp theo
Bài 4: Debugging SEO Issues — Cách dùng Lighthouse SEO audit, Google Search Console reports, và Chrome DevTools để tìm và fix lỗi SEO phổ biến: missing titles, broken canonical URLs, và mobile usability issues.