Portable Text: khi content là data, không phải markup
Viết content một lần, dùng được ở mọi nơi — website, newsletter, PDF, app. Không còn cảnh copy-paste mệt mỏi mỗi khi đổi nền tảng.
Ai từng phải chuyển content từ một CMS cũ sang một CMS mới đều hiểu cái cảm giác khó chịu này — <div class="hero"> ở chỗ này, <section class="banner"> ở chỗ kia, inline style rải rác khắp nơi, shortcode của một plugin đã không còn tồn tại nữa vẫn nằm chờ gây lỗi. Content lẽ ra phải là thông tin thuần túy, nhưng khi được lưu dưới dạng HTML thì nó trở thành một mớ markup có thông tin dính kèm — và cái “markup” đó luôn là phần bạn không mang đi được sang bối cảnh khác.
Portable Text tháo cái thắt nút này bằng một ý tưởng rất đơn giản: đừng lưu markup, hãy lưu data. Và khi content đã ở dạng data thì render nó ra HTML cho website, render ra text cho newsletter, hay render ra PDF cho client — tất cả đều chỉ là các cách đọc khác nhau của cùng một nguồn thông tin duy nhất.

Portable Text trông như thế nào
Nói ngắn gọn để hình dung: một tài liệu Portable Text là một mảng JSON, mỗi phần tử trong mảng là một “block” có _type riêng để đánh dấu nó là loại gì. Khi _type là "block", đó là rich text bình thường (đoạn văn, heading, list). Khi _type là gì khác — ví dụ "image", "marketing.hero", hay "code" — đó là một custom block với cấu trúc do chính bạn định nghĩa cho dự án của mình.
Một block rich text tiêu chuẩn trông như thế này:
{
"_type": "block",
"_key": "abc123",
"style": "normal",
"children": [
{"_type": "span", "text": "Đọc thêm tại "},
{"_type": "span", "text": "documentation", "marks": ["link1"]},
{"_type": "span", "text": "."}
],
"markDefs": [
{"_key": "link1", "_type": "link", "href": "/docs"}
]
}
Trong đó children là mảng các “span” — mỗi span là một đoạn text kèm theo marks để đánh dấu nó bold, italic, hay là một link. Phần markDefs hoạt động như một bảng tra cứu: nếu hai span cùng trỏ tới link /docs, bạn không cần lặp lại thông tin link đó hai lần, mà chỉ cần đặt nó một lần trong markDefs rồi cả hai span cùng reference tới _key tên "link1" là xong. Còn _key thì gắn ID ổn định vào từng node, nhờ vậy khi hai người cùng chỉnh sửa một tài liệu thì hệ thống biết chính xác ai đã sửa cái gì mà không lẫn lộn.
Điểm thiết kế mình thấy đáng học nhất
Phần mình thấy thú vị nhất khi đọc spec Portable Text là cách nó tách biệt hai loại đánh dấu khác nhau. Decorator là những flag đơn giản như "strong", "em", "code" — chúng chỉ là string trong mảng marks, không mang dữ liệu gì khác ngoài việc nói “đoạn này đậm” hoặc “đoạn này nghiêng”. Còn annotation là những marker có dữ liệu đi kèm như link, reference tới tài liệu khác, hay footnote — vì có dữ liệu nên chúng không nằm trên span mà nằm riêng trong markDefs, span chỉ giữ một reference tới chúng bằng _key.
Cách tách này nghe hơi kỹ thuật nhưng hệ quả của nó rất thực tế trong công việc hàng ngày: dữ liệu không bao giờ bị duplicate. Nếu bạn đổi một link từ /docs sang /documentation, bạn chỉ cần sửa một chỗ duy nhất trong markDefs, không phải chạy đi sửa mười chỗ nằm rải rác trong content. Và khi so sánh hai phiên bản của cùng một tài liệu, diff sẽ sạch — bạn thấy rõ “link này đã thay đổi” thay vì thấy “đoạn text này thay đổi ở mười vị trí khác nhau”.

Custom block — chỗ Portable Text khác biệt thật sự
Phần mạnh nhất của Portable Text không phải là rich text, mà là khả năng định nghĩa custom block. Bạn có thể tạo một block như {_type: "marketing.hero", heading, subheading, cta} và đặt nó nằm cạnh các block paragraph trong cùng một mảng content, rồi cho renderer biết khi nào gặp _type này thì dùng component tương ứng để hiển thị.
Đây chính là cách EmDash (CMS mình đang dùng cho arealisticdreamer.com) lưu marketing template — toàn bộ home page là một field duy nhất tên content chứa một mảng Portable Text, bên trong mảng đó có các block hero, features, testimonials, CTA xen kẽ với các đoạn paragraph thông thường. Renderer bên phía Astro chỉ cần map _type sang component tương ứng:
import { PortableText } from "emdash/ui";
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
<PortableText value={value} components={{ type: marketingTypes }} />
Muốn thêm một loại section mới vào template? Bạn chỉ cần định nghĩa thêm một _type, viết thêm một component .astro cho nó, và map nó vào trong object trên. Editor bên phía content chỉ cần mở admin UI và chọn “thêm Hero section” từ một danh sách — họ không cần biết gì về code, về type, hay về mảng JSON bên dưới.
Vì sao cùng một nguồn content lại render được nhiều nơi
Đây là lợi ích mà mình thấy ít người nói tới nhưng theo mình nó là lý do quan trọng nhất để chuyển sang Portable Text. Cùng một mảng JSON content, bạn có thể dùng các package khác nhau để render nó thành HTML string cho site tĩnh, React component cho Next.js, Astro component cho Astro site, PDF qua React PDF, Markdown cho newsletter hoặc để feed vào AI training data, hay plain text cho search index. Mỗi package làm một việc: đọc JSON đó và dịch sang định dạng output tương ứng.
Điều này có nghĩa là bạn viết content một lần duy nhất, và cùng content đó chạy được ở mọi kênh bạn cần. Không phải parse HTML để lọc ra text sạch, không phải viết regex để bóc inline style, không phải viết lại content cho từng nền tảng. Ai đã từng viết một bài blog rồi phải copy thủ công sang newsletter, rồi lại copy sang LinkedIn để đăng lại — đều hiểu vấn đề này đáng giá bao nhiêu thời gian trong một năm.
Liên quan gì tới hướng agent-first
Đây là chỗ mình thấy Portable Text ăn khớp rất tự nhiên với hướng agent-first builder mà mình đang đẩy mấy tháng gần đây. Khi content được lưu dưới dạng JSON có type rõ ràng, agent đọc được content một cách tự nhiên mà không cần bước trung gian nào — không phải parse HTML, không phải đoán ngữ nghĩa từ tên class CSS, không phải xử lý hàng loạt edge case của inline style mà mỗi site mỗi khác. Agent nhìn vào một mảng Portable Text là biết ngay đây là hero section, heading của nó là gì, CTA của nó là gì, paragraph nào thuộc về body — tất cả đều có nhãn rõ ràng ngay trong cấu trúc dữ liệu.
Ngược lại, nếu content được lưu dưới dạng HTML string, agent phải đi qua bốn bước trước khi hiểu được nội dung: parse HTML thành cây DOM, đoán semantic từ class name (mà class name này thì mỗi site mỗi khác, không có chuẩn chung), xử lý hàng tá edge case cho inline style và custom tag, rồi retry khi markup không đủ rõ để tự suy luận. Portable Text bỏ hẳn cả bốn bước đó vì bản thân nó đã là contract chứ không phải markup — agent đọc _type: "marketing.hero" là biết ngay đây là hero, không cần suy đoán gì thêm.
Đây cũng chính là lý do EmDash được build trên nền Portable Text ngay từ đầu. EmDash định vị mình là CMS cho agent-first platform, nên việc agent có thể đọc, edit, và generate content thông qua MCP server (mà không phải loay hoay parse HTML) là yêu cầu nền tảng chứ không phải tính năng thêm vào sau.

Session 3 của workshop
Workshop Agent-First Builder có ba session, và Session 3 (thứ Ba 2026-04-21, 8PM) là buổi mình sẽ demo live việc build một website bằng Astro và EmDash trên Cloudflare. Track B của session sẽ dùng đúng pattern Portable Text mình vừa mô tả ở trên, nên nếu bạn đang suy nghĩ về cách tổ chức content cho site sắp tới thì buổi này sẽ cho bạn thấy nó vận hành thực tế như thế nào.
Nếu bạn tham gia, bạn không cần phải học spec Portable Text từ đầu để theo được — mental model đủ dùng chỉ gói gọn trong một câu: content là một mảng JSON, mỗi element là một block có _type, renderer map _type sang component tương ứng, hết. Những chi tiết spec như decorator, markDefs, span là dev concern và editor tương tác qua admin UI chứ không viết JSON tay bao giờ.
Vài điểm cần biết trước
Spec Portable Text đang mang số version v0.0.1 Working Draft, nghe có vẻ đáng lo nhưng thực tế format này đã stable từ năm 2018 và đang chạy hàng triệu document trong production của Sanity — bạn không cần để ý nhiều đến con số version đó. Governance của spec cũng khá mở: ban đầu do Sanity.io khởi xướng, sau đó nằm dưới GitHub org portabletext mà không có foundation chính thức, nhưng đủ ổn định để dùng cho production thực sự.
Về renderer, mỗi framework có package riêng của mình — React dùng @portabletext/react, Vue dùng @portabletext/vue, Svelte dùng @portabletext/svelte, v.v. Riêng với Astro thì package chính thức là community-maintained (không nằm trong org portabletext), còn nếu dùng EmDash thì nó ship kèm emdash/ui vốn là implementation riêng nhưng tuân thủ đầy đủ spec. Bạn chỉ cần chú ý đừng cài nhầm package giữa các framework là được.
Khi nào nên dùng Portable Text
Ngắn gọn thì Portable Text giải quyết rất tốt cái vấn đề mà mình nghĩ là lớn nhất của content management hiện đại: content không nên bị khóa chặt vào một render target duy nhất. HTML khóa bạn vào browser, Markdown khóa bạn vào flat text, MDX khóa bạn vào JavaScript runtime — và mỗi lần bạn muốn đưa content sang một kênh mới thì đều phải làm thêm một bước biến đổi.
Portable Text đơn giản chỉ là JSON array có type — bạn lưu nó ở đâu cũng được, render nó ra đâu cũng được, agent đọc được ngay và editor vẫn edit được qua UI thân thiện. Đây là cách content nên được lưu khi bạn build cho nhiều kênh khác nhau, nhiều loại “người dùng” khác nhau (bao gồm cả agent), và cho tương lai lâu dài của dự án — chứ không phải chỉ cho release sắp tới.
Nếu bạn đang build một platform hoặc một site mà content sẽ phải sống ở nhiều nơi — website, newsletter, app, AI system — Portable Text đáng để bạn dành một buổi thử nghiệm.
Tham khảo: