← Tất cả bài viết
Xây Website Next.js Với Sanity: Hướng Dẫn Thực Chiến
Xây website Next.js với Sanity đúng chuẩn production: schema page-builder, query GROQ, ảnh CMS, SEO metadata và revalidation theo tag — kèm code thật và các cạm bẫy.

Ghép Next.js + Sanity lại không phải phần khó — bài tutorial dựng schema chỉ mất một buổi chiều. Phần khó là những quyết định chỉ lộ ra khi lên production: làm sao để query GROQ không phình ra theo từng section, làm sao render một website pixel-perfect trước khi dataset có một document nào, và làm sao để CMS xuất bản gần như tức thì mà không gọi API Sanity ở mọi request. Đây là cách tôi thực sự triển khai trên dự án khách hàng — kèm code và các đánh đổi thật, không phải bản demo đường thẳng.
Nếu bạn muốn góc nhìn của người thuê — vì sao một partner làm chủ cả lớp chuyển động lẫn CMS lại quan trọng — hãy đọc một fullstack creative developer mang lại gì. Bài này là phần kỹ thuật bên dưới.
Vì sao chọn Sanity cho website Next.js
Sanity là một headless CMS, API-first: nội dung lưu dưới dạng JSON có cấu trúc, bạn truy vấn bằng GROQ, và rich text là Portable Text (một mảng các block, không phải chuỗi HTML). Cấu trúc đó chính là thứ bạn cần phía sau một dự án Next.js thiên về thiết kế — bạn kiểm soát markup, nên animation, WebGL và layout nằm hoàn toàn trong code, còn biên tập viên làm chủ chữ và ảnh.
| Vấn đề | Cách Sanity xử lý |
|---|---|
| Cấu trúc nội dung | Bạn định nghĩa schema bằng code (versioned, review được) |
| Truy vấn | GROQ — projection nặn ra đúng payload component cần |
| Rich text | Portable Text (block có cấu trúc → renderer React của bạn) |
| Ảnh | CDN với transform tức thì (?w=&h=&fit=&auto=format) |
| Xuất bản | Real-time; kết hợp on-demand revalidation của Next.js |
Muốn so với Prismic, Strapi và WordPress headless, xem bài so sánh headless CMS.
Client và biến môi trường
Một client có type, useCdn: true cho API edge-cache nhanh, và một lớp bảo vệ env chặt chẽ. Lớp bảo vệ này quan trọng hơn vẻ ngoài của nó:
// src/lib/sanity/client.ts
import { createClient } from "next-sanity";
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim();
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET?.trim() || "production";
// Coi env rỗng/toàn khoảng trắng là THIẾU — một NEXT_PUBLIC_SANITY_DATASET=""
// trên build host cấu hình sai sẽ lọt qua phép kiểm tra `undefined`, khiến
// createClient ném lỗi ngay lúc module-eval, giết build trước khi có fetch nào chạy.
if (!projectId) throw new Error("Thiếu NEXT_PUBLIC_SANITY_PROJECT_ID");
export const client = createClient({
projectId,
dataset,
apiVersion: "2025-02-19", // ghim ngày; đừng dùng "latest"
useCdn: true,
});
Ghim apiVersion bằng một chuỗi ngày để một thay đổi hành vi GROQ không âm thầm làm sai kết quả. Trim env: một NEXT_PUBLIC_SANITY_DATASET="" rỗng là lỗi kinh điển "chạy ở local, chết trên Vercel" vì nó qua được phép kiểm undefined nhưng Sanity từ chối nó như một tên dataset.
Schema page-builder và Portable Text
Page-builder cho phép biên tập viên lắp một trang từ các section sắp xếp lại được. Mỗi section là một object của Sanity; document page giữ một mảng chúng:
// schema/page.ts
import { defineType, defineArrayMember } from "sanity";
export default defineType({
name: "page",
type: "document",
fields: [
{ name: "title", type: "string" },
{ name: "slug", type: "slug", options: { source: "title" } },
{
name: "sections",
type: "array",
of: [
defineArrayMember({ type: "heroSection" }),
defineArrayMember({ type: "richTextSection" }),
defineArrayMember({ type: "gallerySection" }),
],
},
{ name: "seo", type: "seo" },
],
});
Phía render, một ModuleRenderer switch theo _type → component React. Gặp _type lạ? Log cảnh báo chỉ ở dev rồi render null — đừng bao giờ throw, kẻo một document cũ làm sập cả trang:
function ModuleRenderer({ sections }: { sections: SectionData[] }) {
return sections.map((s) => {
switch (s._type) {
case "heroSection": return <Hero key={s._key} section={s} />;
case "richTextSection": return <RichText key={s._key} section={s} />;
case "gallerySection": return <Gallery key={s._key} section={s} />;
default:
if (process.env.NODE_ENV !== "production")
console.warn(`_type section lạ: ${(s as { _type: string })._type}`);
return null;
}
});
}
Portable Text render qua @portabletext/react với bản đồ component của riêng bạn, để rich text của biên tập viên thừa hưởng typography và component link của bạn thay vì HTML thô:
import { PortableText } from "@portabletext/react";
<PortableText
value={section.body}
components={{
block: { h2: ({ children }) => <h2 className="editorial-h2">{children}</h2> },
marks: { link: ({ children, value }) => <LinkEffect href={value.href}>{children}</LinkEffect> },
}}
/>;
Một spread projection, không query theo từng type
Lỗi tôi gặp nhiều nhất: một query GROQ với projection riêng cho từng type section (_type == "hero" => {...}, _type == "gallery" => {...}). Nó phình theo mỗi section và lệch dần khỏi schema. Cách sửa là thống nhất tên field trên mọi schema section — heading, label, cta, items[], cover, visual — để một spread projection resolve hết. Field giá trị thuần đi theo spread ... miễn phí; bạn chỉ project những field cần GROQ resolve (asset, link, reference):
*[_type == "page" && slug.current == $slug][0]{
_id,
title,
"slug": slug.current,
seo,
sections[]{
...,
_key,
_type,
"coverUrl": cover.asset->url,
"coverAlt": cover.caption,
items[]{
...,
"iconUrl": icon.asset->url,
cta{ label, newTab, "href": coalesce(url, "/" + page->slug.current) }
}
}
}
Thêm một section mới với cùng bộ tên field, nó render qua đúng query cũ — không sửa query một dòng nào. Kỷ luật đó là ranh giới giữa một CMS bạn mở rộng trong vài phút và một CMS cản bạn ở mọi section.
Ảnh: flatten trong GROQ, bỏ urlFor trong React
Component đừng bao giờ nhận object ảnh của Sanity. Hãy resolve URL, alt và tên file ngay trong projection rồi đưa cho component chuỗi thuần. Việc đó dẹp được đống plumbing urlFor() + hotspot + remotePatterns của next/image mà lẽ ra bạn phải luồn khắp nơi:
"coverUrl": cover.asset->url,
"coverAlt": cover.caption,
"coverFilename": cover.asset->originalFilename
Cho mỗi field ảnh một field caption duy nhất, kiêm luôn alt text, rồi dựng một chuỗi fallback để ảnh không bao giờ thiếu alt:
// "hero-dark-01.png" -> "hero dark 01"
const altFromSrc = (src: string) =>
(src.split("/").pop() ?? src).replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " ").trim();
const alt = section.coverAlt || altFromSrc(section.coverFilename || section.coverUrl);
Với link nội bộ, hãy resolve href cũng trong GROQ — một coalesce(select(...)) biến reference nội bộ thành "/" + slug (và slug home thành "/"), để component chỉ việc render { label, href, newTab } mà không đụng tới đồ thị link của Sanity.
SEO metadata lấy từ CMS
Gom metadata vào một builder duy nhất lib/seo.ts, merge SEO cấp trang đè lên default của site — đừng viết generateMetadata inline theo từng route. Bọc fetcher trong cache() của React để generateMetadata, layout và page dùng chung một request mỗi lần render:
import { cache } from "react";
export const getPageBySlug = cache(async (slug: string) => {
try {
return await client.fetch(pageBySlugQuery, { slug });
} catch {
return null; // design-first: render fallback khi CMS rỗng/không truy cập được
}
});
export async function generateMetadata({ params }): Promise<Metadata> {
const page = await getPageBySlug((await params).slug);
return {
title: page?.seo?.metaTitle ?? page?.title ?? "…",
description: page?.seo?.metaDescription ?? "…",
openGraph: { images: page?.seo?.ogImageUrl ? [page.seo.ogImageUrl] : [] },
};
}
Mọi document type có slug riêng (post, project, page) nên gắn cùng một object seo dùng chung, để biên tập viên có quyền chỉnh title/OG/canonical/noindex cho từng mục — không phải một description tự chế từ field tóm tắt.
Revalidation: theo tag, kích bằng webhook
Bạn muốn tốc độ CDN của useCdn: true và xuất bản gần như tức thì. Câu trả lời ổn định, có tài liệu là revalidation theo tag: gắn tag cho mỗi fetch bằng các document type nó đọc, rồi một webhook Sanity gọi tới route handler chạy revalidateTag.
Gắn tag cho fetch:
const page = await client.fetch(pageBySlugQuery, { slug }, {
next: { tags: ["page", `page:${slug}`] },
});
Route webhook, có xác thực chữ ký qua next-sanity/webhook:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { parseBody } from "next-sanity/webhook";
import { NextResponse, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { isValidSignature, body } = await parseBody<{ _type: string; slug?: { current?: string } }>(
req,
process.env.SANITY_REVALIDATE_SECRET,
);
if (!isValidSignature) return new NextResponse("Chữ ký không hợp lệ", { status: 401 });
if (!body?._type) return new NextResponse("Payload sai", { status: 400 });
revalidateTag(body._type);
if (body.slug?.current) revalidateTag(`${body._type}:${body.slug.current}`);
return NextResponse.json({ revalidated: true, tag: body._type });
}
Thêm một GROQ-powered webhook trong dashboard quản lý của Sanity, trỏ tới URL đó với cùng secret, project { _type, slug }. Một cạm bẫy tài liệu có nêu: revalidation theo thời gian và theo tag không kết hợp được — nếu một fetch có tags, giá trị revalidate của nó bị bỏ qua. Chọn một mô hình cho mỗi fetch.
Với những site marketing nhỏ mà một phút cũ dữ liệu chẳng sao, bỏ hẳn webhook: useCdn: true + export const revalidate = 60 trên layout, page và sitemap. Chỉ thêm plumbing webhook khi khách cần xuất bản tức thì. Đây cũng chính là bài học chi phí fetch trong vì sao Next.js cứ gọi CMS ở mọi request — một fetch không tag, không cache âm thầm tính tiền bạn (và đè tải database) ở mỗi lượt truy cập, đúng kiểu một site ít traffic vẫn có thể chạm giới hạn Neon.
Design-first: render trước khi dataset tồn tại
Thực tế agency: site lên pixel-perfect với dataset rỗng, nội dung được nối vào sau. Nên site không bao giờ được notFound() hay render trắng chỉ vì Sanity rỗng. Hai quy tắc làm nó bền:
- Fetcher nuốt lỗi và trả
null/[](xemtry/catchở trên). - Mỗi section nhận prop optional và fallback theo từng field về đúng copy trong thiết kế:
const DEFAULT_HEADING = "Một môi trường không hệ quả.";
export default function Hero({ section }: { section?: HeroSectionData }) {
const heading = section?.heading || DEFAULT_HEADING;
const items = section?.items?.length ? section.items : DEFAULT_ITEMS;
// render đầy đủ với zero nội dung CMS
}
Dành notFound() cho trường hợp slug thực sự không tồn tại trên route chi tiết — đừng dùng cho "CMS chưa được set up." Route home render một bố cục module hardcode cho tới khi có page slug home, và generateStaticParams được try/catch về [] để build không bao giờ fail vì CMS rỗng.
Những cạm bẫy đáng giá cả bài viết này
| Cạm bẫy | Cách sửa |
|---|---|
createClient ném lỗi lúc build vì env rỗng | Trim env; coi rỗng/khoảng trắng là thiếu, rồi fallback |
| Projection GROQ phình theo từng type section | Thống nhất tên field + một spread projection |
urlFor() + hotspot + remotePatterns khắp nơi | Flatten url/caption/filename trong GROQ; đưa chuỗi |
| Trang trắng vì dataset rỗng | Prop section? optional + fallback thiết kế theo field |
Xuất bản chậm với useCdn: true | Gắn tag fetch + webhook → revalidateTag |
revalidate bị bỏ qua trên fetch có tag | Theo thời gian và theo tag không kết hợp — chọn một |
apiVersion: "latest" | Ghim chuỗi ngày để hành vi GROQ ổn định |
FAQ
Tôi có cần @sanity/image-url và next/image không?
Không, nếu bạn đã flatten URL ảnh trong GROQ. CDN của Sanity transform bằng query param (?w=1200&auto=format), nên bạn có thể đưa URL thuần cho next/image (hoặc một <img> trơn) và bỏ plumbing urlFor() + hotspot cho hầu hết layout. Chỉ dùng @sanity/image-url khi bạn thật sự cần art direction theo hotspot/crop.
Làm sao preview bản nháp tức thì?
Xếp draft-mode + công cụ Presentation lên trên setup này — nhưng chỉ khi khách cần live preview. Với đa số dự án, revalidation theo tag lúc xuất bản là đủ, và ít plumbing hơn nhiều.
GROQ hay GraphQL trên Sanity?
GROQ là ngôn ngữ truy vấn native và mạnh hơn — projection, join (->) và field tính toán trong một query. Dùng nó trừ khi một ràng buộc bên ngoài bắt buộc GraphQL.
Có dùng đúng setup này cho blog hay site marketing được không?
Được — pattern page-builder phủ các trang marketing, còn document type có slug (post, project) tái dùng cùng object seo chung và cùng cơ chế revalidation. Nó scale từ one-pager tới site nhiều nội dung.
Triển khai cùng một partner làm chủ cả stack
Giá trị của một dự án Next.js + Sanity không nằm ở schema — mà ở phán đoán quanh nó: giữ projection gọn, render design-first, và làm xuất bản mượt mà không đốt ngân sách API. Đó là cùng một kỷ luật đằng sau mọi site tôi ship, từ kho dự án tới chính site này.
Nếu bạn đang lên kế hoạch xây mới — hay chuyển khỏi một template hoặc một theme WordPress rối — hãy trao đổi. Bạn có cả độ mượt motion, WebGL và một CMS đội của bạn thật sự vận hành được.


