Le Duy Khuong

Chuỗi: seo-nextjs-metadata · Phần 3

Năng suất & công cụ dev

Sitemap & Robots trong Static Export

Sitemap XML, robots.txt, withLocales, force-static

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:

  1. userAgent: "*" — Áp dụng cho TẤT CẢ bots (Googlebot, Bingbot, social crawlers...).
  2. allow: "/" — Cho phép crawl toàn bộ site.
  3. 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.
  4. 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ĩaGoogle dùng?
locURL absoluteCó — bắt buộc
lastmodLần cuối cập nhậtCó — dùng để ưu tiên re-crawl
changefreqTần suất thay đổiKhô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 loclastmod. Các field changefreqpriority 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 changeFrequencypriority 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 pagePriorityLý do
Homepage1.0Entry point, highest importance
Blog landing0.9Content hub
About0.8Brand credibility
Blog posts0.7Core content
Topics, Pillars, Series0.6Navigation/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.xmlrobots.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):

  1. Vào GSC → chọn property leduykhuong.com
  2. Menu trái → "Sitemaps"
  3. 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 -10

Câ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.txt

Câ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 loclastmod, bỏ qua changefreqpriority.
  • withLocales helper — 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.

LDK

Le Duy Khuong

AI Transformation & Digital Strategy. Writing about agentic systems, engineering leadership, and building in public.