Le Duy Khuong

Chuỗi: seo-json-ld · Phần 2

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

BlogPosting Schema Deep Dive

BlogPosting fields, rich results, author vs publisher

2026-03-207 phút đọcVI

BlogPosting Schema Deep Dive

Mở đầu

Bài trước giới thiệu tổng quan Schema.org và JSON-LD. Bài này sẽ đi sâu vào type quan trọng nhất cho leduykhuong.com: BlogPosting. Mỗi blog post trên site đều inject một BlogPosting schema qua getBlogPostJsonLd() trong lib/seo.ts.

Tại sao cần hiểu chi tiết? Vì mỗi field trong BlogPosting có ý nghĩa riêng với Google. Một số fields là required cho rich results, một số là recommended, và một số optional nhưng giúp Google hiểu content tốt hơn. Biết field nào quan trọng → biết cần điền gì trong MDX frontmatter.

Mục tiêu: Phân tích từng field trong getBlogPostJsonLd(), hiểu Google dùng chúng thế nào, và best practices cho personal blog.


getBlogPostJsonLd() — Toàn bộ function

// lib/seo.ts
export function getBlogPostJsonLd(post: Post) {
  return {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.seoTitle || post.title,
    description: getSeoDescription(post),
    author: {
      "@type": "Person",
      name: SITE_CONFIG.author.name,
      url: SITE_CONFIG.author.url,
    },
    datePublished: post.date,
    dateModified: post.date,
    url: getCanonicalUrl(`/blog/${post.slug}`),
    image: post.ogImage
      ? `${SITE_CONFIG.baseUrl}${post.ogImage}`
      : `${SITE_CONFIG.baseUrl}/og-default.png`,
    publisher: {
      "@type": "Person",
      name: SITE_CONFIG.author.name,
      url: SITE_CONFIG.author.url,
    },
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": getCanonicalUrl(`/blog/${post.slug}`),
    },
    inLanguage: post.lang === "vi" ? "vi" : "en",
    ...(post.keywords ? { keywords: post.keywords.join(", ") } : {}),
  };
}

Phân tích từng field

headline — Required

headline: post.seoTitle || post.title,
  • Google dùng cho: Article rich result title
  • Priority: seoTitle (nếu tác giả khai báo riêng) > title (tiêu đề chính)
  • Best practice: Dưới 110 ký tự. Google cắt headline dài trong rich results.

Tại sao có seoTitle riêng? Vì đôi khi title cho reader (dài, descriptive) khác title cho search (ngắn, keyword-rich). Ví dụ:

  • title: "Tại sao tôi quyết định viết public và bài học sau 6 tháng"
  • seoTitle: "Learning in Public — 6 tháng kinh nghiệm"
description: getSeoDescription(post),

Function getSeoDescription() có priority chain:

  1. post.seoDescription — Tác giả viết riêng cho SEO
  2. post.excerpt — Tóm tắt bài (truncate 155 ký tự)
  3. Fallback: site-level description

Google dùng cho: Snippet text dưới title trong search results. 155 ký tự là soft limit — Google có thể cắt ngắn hơn hoặc tự generate snippet từ content.

author — Required cho rich results

author: {
  "@type": "Person",
  name: SITE_CONFIG.author.name,    // "Le Duy Khuong"
  url: SITE_CONFIG.author.url,      // "https://leduykhuong.com"
},
  • @type: "Person" — Tác giả là cá nhân (không phải Organization)
  • name — Tên hiển thị trong rich results
  • url — Link tới profile page. Google dùng để build Knowledge Panel

Best practice cho personal blog: authorpublisher cùng là Person (cùng người). Với blog công ty, publisher sẽ là Organization.

datePublisheddateModified — Required

datePublished: post.date,
dateModified: post.date,
  • Google dùng cho: Hiển thị "Mar 20, 2026" trong search results
  • Format: ISO 8601 string (ví dụ: "2026-03-20")
  • Hiện tại: dateModified = datePublished (chưa track modified date riêng)

Cải thiện trong tương lai: Track lastModified trong MDX frontmatter. Google ưu tiên hiển thị dateModified nếu khác datePublished — cho thấy content được cập nhật.

url — Required

url: getCanonicalUrl(`/blog/${post.slug}`),

URL canonical — phải match với <link rel="canonical"> trong metadata. Nếu khác nhau, Google sẽ confused về URL nào là chính.

image: post.ogImage
  ? `${SITE_CONFIG.baseUrl}${post.ogImage}`
  : `${SITE_CONFIG.baseUrl}/og-default.png`,
  • Phải absolute URL — bắt đầu https://
  • Fallback: /og-default.png khi post không có ảnh riêng
  • Google: Bài có image có cơ hội hiển thị rich result cao hơn bài không có image

publisher — Required

publisher: {
  "@type": "Person",
  name: SITE_CONFIG.author.name,
  url: SITE_CONFIG.author.url,
},

Personal blog: Publisher = Author (cùng Person). Đây là pattern hợp lệ.

Organization blog: Publisher nên là:

{
  "@type": "Organization",
  "name": "Company Name",
  "logo": { "@type": "ImageObject", "url": "https://example.com/logo.png" }
}
mainEntityOfPage: {
  "@type": "WebPage",
  "@id": getCanonicalUrl(`/blog/${post.slug}`),
},

Nói cho Google: "Entity chính trên page này là BlogPosting kia." Nếu page có nhiều entities (sidebar articles, related posts), mainEntityOfPage chỉ rõ entity nào là main content.

@id — Identifier unique cho WebPage. Dùng canonical URL là best practice.

inLanguage: post.lang === "vi" ? "vi" : "en",

IETF language tag: "vi" hoặc "en". Giúp Google phân loại content theo ngôn ngữ — quan trọng cho multilingual sites.

keywords — Optional

...(post.keywords ? { keywords: post.keywords.join(", ") } : {}),

Conditional spread: chỉ thêm keywords nếu post có khai báo trong frontmatter. Keywords là comma-separated string.

Lưu ý: Google KHÔNG dùng keywords cho ranking (từ 2009). Nhưng field này vẫn hữu ích cho:

  • Các search engines khác (Bing, Yandex)
  • Internal search/filtering
  • Content organization

Google Rich Results Requirements

Google có danh sách required/recommended fields cho Article rich results:

FieldStatusleduykhuong.comGhi chú
headlineRequiredTừ seoTitle/title
imageRequiredTừ ogImage/default
datePublishedRequiredTừ frontmatter date
author.nameRequiredConfig constant
author.urlRecommendedConfig constant
dateModifiedRecommended⚠️= datePublished
publisherRecommendedSame as author
descriptionRecommendedTừ seoDescription/excerpt
mainEntityOfPageRecommendedCanonical URL

Status: leduykhuong.com đáp ứng tất cả required + hầu hết recommended fields. Cải thiện duy nhất: track dateModified riêng.


Author vs Publisher — Khi nào khác nhau?

Trên leduykhuong.com, cả hai đều là cùng một Person. Nhưng trong các context khác:

Tình huốngAuthorPublisher
Personal blogPerson (tác giả)Person (cùng người)
Company blogPerson (nhân viên viết)Organization (công ty)
News sitePerson (phóng viên)Organization (tòa soạn)
Guest postPerson (khách mời)Person/Org (chủ blog)

Google dùng cả hai: Author hiển thị trong rich results, Publisher dùng để verify source credibility.


Mapping MDX Frontmatter → Schema

Dữ liệu cho schema đến từ MDX frontmatter:

---
title: "Learning in Public"
seoTitle: "Learning in Public — 6 tháng kinh nghiệm"
date: "2026-03-20"
excerpt: "Tại sao viết giúp suy nghĩ rõ hơn..."
seoDescription: "Bài viết chia sẻ 6 tháng learning in public..."
ogImage: "/generated/learning-in-public.webp"
lang: vi
keywords: ["learning", "writing", "public"]
---
Frontmatter fieldSchema fieldFallback
seoTitleheadlinetitle
seoDescriptiondescriptionexcerpt → site description
datedatePublished, dateModified
ogImageimage/og-default.png
langinLanguage"en"
keywordskeywordsOmit field

Thiết kế principle: Mỗi MDX field có fallback. Tác giả chỉ cần điền titledate tối thiểu — schema vẫn đầy đủ required fields nhờ defaults và fallbacks.


Thực hành

Bài tập 1: Đọc raw JSON-LD output

cd ACE-component/ACE-leduykhuong-site && npm run build
 
# Extract và format BlogPosting JSON-LD
grep -oP '(?<=ld\+json">)\{[^}]*BlogPosting[^<]*(?=</script>)' \
  out/vi/blog/learning-in-public.html | python3 -m json.tool

Câu hỏi: Output có bao nhiêu fields? datePublisheddateModified có giống nhau không?

Bài tập 2: So sánh blog posts có/không có ogImage

Tìm 2 blog posts: 1 có ogImage trong frontmatter, 1 không có. So sánh image field trong JSON-LD output.

Bài tập 3: Validate bằng Rich Results Test

  1. Deploy site (hoặc dùng npx serve out)
  2. Mở Rich Results Test
  3. Nhập URL blog post
  4. Kiểm tra: bao nhiêu detected items? Có warnings không?

Tóm tắt

  • BlogPosting có ~12 fields; Google yêu cầu 4 required: headline, image, datePublished, author.name
  • getBlogPostJsonLd() centralize logic: MDX frontmatter → Schema object với fallbacks
  • Author = Publisher hợp lệ cho personal blog; company blog cần Publisher là Organization
  • seoTitle cho phép SEO title khác display title — dùng khi cần keyword optimization
  • dateModified nên track riêng (hiện = datePublished) — Google ưu tiên updated content
  • keywords Google bỏ qua cho ranking, nhưng hữu ích cho Bing và internal search

Bài tiếp theo

Bài 7: Multi-Schema Strategy — leduykhuong.com inject nhiều schema types trên cùng page (BlogPosting + BreadcrumbList). Bài tiếp sẽ phân tích WebSite schema (homepage), Person schema (about page), và strategy kết hợp nhiều schemas.

LDK

Le Duy Khuong

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