← Tất cả bài viết

[ Blog ]

27 tháng 6, 2026

12 phút đọc

Chuyên mục: Góc nhìn

Headless WordPress Với Frontend Next.js Hiện Đại

Tách rời WordPress và render frontend Next.js nhanh — lấy bài/trang qua REST API hoặc WPGraphQL, gắn ISR, media, SEO, preview, và né các bẫy auth/CORS/cache.

Next.jsWordPressHeadless CMSWPGraphQLREST APIKiến trúc
Headless WordPress Với Frontend Next.js Hiện Đại

Đa số site WordPress không cần một CMS mới — chúng cần một frontend mới. Nếu khách của bạn đã quen dashboard WordPress nhưng theme PHP thì chậm, cứng nhắc và gần như không thể làm chuyển động, nước đi là chuyển sang headless WordPress với frontend Next.js hiện đại: giữ WordPress thuần làm back end nội dung cho editor và render site thật bằng Next.js. Đây là con đường "bỏ theme" tôi đã triển khai trên dự án khách thật — trong đó có Iventions, làm cùng studio SERIOUS.BUSINESS và nhà thiết kế Huy Phan. Bên dưới là toàn bộ pipeline: lấy bài và trang, ISR và revalidate theo tag, media và SEO, preview, cùng các bẫy auth/CORS/cache khiến hầu hết bản dựng đầu tiên vấp ngã.

Một lưu ý về công sức: ở Iventions tôi là developer; phần thiết kế và art direction là của Huy Phan + SERIOUS.BUSINESS. Mọi thứ ở đây là góc kỹ thuật của bản dựng đó.

Vì sao chọn headless WordPress (và bỏ theme vào sọt rác)

Bản năng của nhiều dev là chọn một headless CMS bóng bẩy (Sanity, Payload) rồi không đụng vào WordPress nữa. Nhưng trong dự án thật, yếu tố quyết định hiếm khi là gu của dev — mà là ai sẽ sửa nội dung sau khi ra mắt. Cực kỳ nhiều đội marketing, agency, founder đang sống trong WordPress mỗi ngày, và đào tạo lại họ sang công cụ mới là một chi phí thật.

"Headless" nghĩa là WordPress giữ dashboard, thư viện media, phân quyền và quy trình biên tập — nhưng bạn bỏ hoàn toàn lớp theme. Bản thân site trở thành một app Next.js đọc nội dung qua API. Sự tách bạch đó chính là toàn bộ giá trị:

  • Editor giữ WordPress. Không đào tạo lại, phân quyền trưởng thành, hệ sinh thái plugin khổng lồ cho SEO, form, custom field.
  • Người dùng nhận frontend nhanh, riêng biệt. React Server Components, caching chi tiết, tương tác được code-split, và một hệ thống motion bạn thật sự sở hữu.
  • Hai bên tách rời. Thiết kế lại frontend mà không cần di trú nội dung, hoặc đổi CMS sau này mà không đụng UI. Đó là phần "future-proof" có thật.

Đây là một trong các mô hình giao hàng tôi nói trong một fullstack creative developer làm những gì — làm chủ cả back end của editor lẫn frontend của người dùng, từ một đối tác.

Hai cách đọc nội dung đã được tài liệu hóa: REST vs WPGraphQL

WordPress có sẵn REST API, còn WPGraphQL thêm một endpoint GraphQL có kiểu qua plugin. Cả hai đều là đường ổn định, có tài liệu — chọn tùy mức độ chính xác từng field của component.

CáchLà gìHợp khi nào
WP REST APIEndpoint /wp-json có sẵnKhông thêm plugin, URL dễ cache, bài/trang chuẩn
WPGraphQLEndpoint GraphQL trên WP + ACFMuốn lấy đúng field một component cần trong một request

Với các build dạng block, nặng art direction, tôi chọn WPGraphQL vì frontend hướng component ăn rơ tự nhiên với query chính xác từng field — một block Hero xin tiêu đề, media và config của nó trong một lượt. Với site kiểu blog-cộng-trang tiêu chuẩn thì REST API hoàn toàn ổn và bớt một thứ phải lo. Phần còn lại của bài dùng REST vì không cần plugin và URL rất dễ cache.

Lấy bài và trang từ WordPress REST API

Trong App Router, việc fetch là chuyện của server — thông tin đăng nhập WordPress và trọng lượng query không bao giờ chạm tới trình duyệt. Đây là một danh sách bài có phân trang. Bẫy nhiều người bỏ lỡ: phân trang nằm ở header phản hồi, không phải body.

// lib/wp.ts — chỉ chạy trên server
const WP = process.env.WP_URL // vd https://cms.example.com

export async function getPosts(page = 1) {
  const res = await fetch(`${WP}/wp-json/wp/v2/posts?per_page=12&page=${page}&_embed`, {
    // ISR: trả HTML đã cache, làm mới ngầm mỗi 5 phút
    next: { revalidate: 300, tags: ['posts'] },
  })
  if (!res.ok) throw new Error('WP posts fetch failed')
  return {
    posts: await res.json(),
    totalPages: Number(res.headers.get('X-WP-TotalPages') ?? 1),
  }
}

Cờ &_embed âm thầm gánh phần nặng — nó nhét sẵn featured image, tác giả và các term taxonomy vào một object _embedded để bạn không phải bắn thêm ba request mỗi card. Lấy một bài hay trang theo slug cũng cùng dáng:

export async function getPost(slug: string) {
  const res = await fetch(`${WP}/wp-json/wp/v2/posts?slug=${slug}&_embed`, {
    next: { revalidate: 300, tags: [`post:${slug}`] },
  })
  const [post] = await res.json() // query theo slug trả về một mảng
  return post ?? null
}
// trang nằm ở /wp-json/wp/v2/pages?slug=...

Dòng next: { revalidate, tags } đó là phần quan trọng nhất của cả kiến trúc — chính nó giúp một site chạy nền WordPress đạt Core Web Vitals ngang site tĩnh hoàn toàn. Chi tiết ở dưới.

Với _embed, featured image và các kích thước responsive của nó trả về ngay trong payload. Hãy ánh xạ dáng REST thô thành một object gọn mà component dùng, và lấy các field SEO mà plugin của khách phơi ra — YoastRank Math đều đẩy SEO có cấu trúc vào phản hồi REST (Yoast là object yoast_head_json) khi được bật:

function mapPost(p: WpPost) {
  const media = p._embedded?.['wp:featuredmedia']?.[0]
  return {
    title: p.title.rendered,
    html: p.content.rendered, // render qua sanitizer, đừng đổ thẳng bằng lòng tin
    cover: media?.source_url,
    coverAlt: media?.alt_text ?? '',
    sizes: media?.media_details?.sizes, // thumbnail / medium / large / full
    seo: p.yoast_head_json, // title, description, og_image, canonical…
  }
}

Các field SEO đó nạp thẳng vào generateMetadata của Next.js, nên cấu hình Yoast của editor vẫn điều khiển <title>, description và Open Graph trên frontend headless:

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost((await params).slug)
  const seo = post?.yoast_head_json
  return {
    title: seo?.title ?? post?.title?.rendered,
    description: seo?.description,
    openGraph: { images: seo?.og_image ?? [] },
    alternates: { canonical: seo?.canonical },
  }
}

Với chính các ảnh, hãy cho URL media WordPress đi qua next/image cùng một mục remotePatterns trỏ host CMS, để frontend vẫn tự resize và dùng định dạng ảnh hiện đại.

ISR và revalidate theo tag: nội dung nhanh mà cập nhật trong vài giây

Lời hứa của headless chỉ đúng nếu chỉnh sửa thật sự xuất hiện mà không cần deploy lại. Mô hình: cache mạnh tay với next: { revalidate, tags }, rồi để một webhook WordPress xóa đúng tag khi đăng bài. Tài liệu caching của Next.js chứng minh điều này — phục vụ HTML tĩnh cache trên CDN mà vẫn làm mới nội dung theo yêu cầu.

// app/api/revalidate/route.ts — WordPress gọi khi save_post
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const { secret, type, slug } = await req.json()
  if (secret !== process.env.WP_REVALIDATE_SECRET) {
    return new Response('Unauthorized', { status: 401 })
  }
  revalidateTag(type === 'page' ? `page:${slug}` : `post:${slug}`)
  revalidateTag('posts') // làm mới mọi listing có chứa nó
  return Response.json({ revalidated: true })
}

Ở phía WordPress, nối một hook save_post nhỏ (hoặc plugin như WP Webhooks) POST slug kèm một secret dùng chung. Giờ editor bấm Publish là thay đổi lên sóng trong vài giây — cùng trải nghiệm sửa bài, tốc độ site tĩnh. Đây cũng là kỷ luật theo tag mà CMS của chính site này dùng; tôi viết lại kiểu hỏng khi làm sai trong vì sao một fetch CMS không cache âm thầm ngốn tiền.

Preview: hiện bản nháp với Draft Mode + auth

Một bản nháp headless không còn "tự hiện ra" — than phiền lớn nhất của editor ở các build gấp. Bạn cần một route preview vào Draft Mode của Next.js và lấy nội dung không cache, có xác thực. Trỏ link preview của WordPress vào đó (qua filter preview_post_link) kèm một secret dùng chung:

// app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  if (searchParams.get('secret') !== process.env.WP_PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }
  ;(await draftMode()).enable()
  redirect(`/${searchParams.get('slug')}`)
}

Rồi trong fetch, rẽ nhánh theo Draft Mode: khi bật, xin trạng thái draft kèm header auth và bỏ cache để editor thấy đúng ký tự vừa gõ. Từ Next.js 15 trở đi, fetch không còn cache mặc định, nên nói rõ cả hai chiều đều quan trọng:

import { draftMode } from 'next/headers'

export async function getPostForRoute(slug: string) {
  const { isEnabled } = await draftMode()
  const status = isEnabled ? 'draft,publish' : 'publish'
  return fetch(`${WP}/wp-json/wp/v2/posts?slug=${slug}&status=${status}&_embed`,
    isEnabled
      ? { cache: 'no-store', headers: { Authorization: wpAuthHeader() } }
      : { next: { revalidate: 300, tags: [`post:${slug}`] } },
  ).then((r) => r.json())
}

const wpAuthHeader = () =>
  'Basic ' + Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString('base64')

Cái WP_APP_PASSWORD là một Application Password theo từng user (WordPress 5.6+) — cách được tài liệu hóa để xác thực request REST qua HTTPS mà không lộ mật khẩu đăng nhập. Với preview WPGraphQL bạn dùng plugin JWT Authentication thay thế.

Các bẫy thật: auth, CORS và caching

Ba thứ làm hỏng gần như mọi build headless đầu tiên. Không cái nào khó khi đã vấp qua:

  • CORS thường là tự chuốc. Fetch WordPress từ server (RSC, route handler) là không có CORS nào cả — server-to-server. Lỗi CORS nghĩa là bạn đang gọi /wp-json từ trình duyệt; dời lời gọi sang server là nó biến mất. Tiện thể giữ luôn thông tin đăng nhập khỏi client.
  • Caching đã lật ở Next.js 15. fetch giờ không cache mặc định, nên port cẩu thả sẽ dội bom WordPress mỗi request. Luôn nói rõ: next: { revalidate, tags } cho nội dung, cache: 'no-store' chỉ cho preview. Tôi mổ xẻ cái giá của việc làm sai trong bài fetch CMS không cachegiữ database dưới giới hạn connection.
  • Plugin giả định có theme. Vài plugin WordPress chèn markup không bao giờ tới frontend headless. Kiểm tra plugin SEO/form xem có hỗ trợ headless/REST trước khi cam kết — Yoast và Rank Math an toàn vì phơi field REST; một page-builder chỉ xuất template PHP thì không.

Khi nào headless WordPress đáng — và không đáng

Headless là công cụ đúng nhiều hơn bạn tưởng, nhưng không miễn phí. Nói thẳng:

Đáng khi: khách cam kết dùng WordPress làm editor, có lượng biên tập thật, và frontend quá tùy biến, quá nhanh hoặc quá nhiều chuyển động cho một theme — một site flagship thương hiệu hoặc giàu nội dung. Đó đúng là Iventions.

Thừa khi: một site brochure nhỏ hiếm khi đổi nội dung. Chạy hosting WordPress deploy Next.js là hai hệ thống, hai nhịp cập nhật, hai bề mặt bảo mật. Build tĩnh từ Markdown, hoặc một CMS có cấu trúc nhẹ, ít phải bảo trì hơn. Nếu bạn chọn CMS từ đầu thay vì thừa kế WordPress, hãy cân nhắc trong so sánh headless CMS hoặc con đường editor hiện đại trong xây website Next.js với Sanity.

Điểm cộng: frontend chuyển động phủ lên trên

Vì Next.js sở hữu render, frontend có thể art-directed tùy thích mà WordPress không cần biết. Ở Iventions điều đó nghĩa là choreography GSAP và một lớp Three.js — giữ ở 60fps bằng cách chỉ animate transform/opacity, khởi tạo WebGL lười gần viewport, và giới hạn device pixel ratio ở Math.min(devicePixelRatio, 2). Tôi đi sâu vào các ngân sách đó trong Core Web Vitals cho web nhiều chuyển động. Điều này đúng cả với site nội dung thuần: một frontend WordPress nhanh chủ yếu là đừng đẩy việc xuống client — cache mạnh, hydrate ít.

Bằng chứng nó hoạt động

Iventions — dựng headless trên WordPress với frontend Next.js — đoạt CSS Design Awards Website of the MonthAwwwards Site of the Day + Developer Award, trong khi đội biên tập quản lý nội dung bằng WordPress suốt quá trình. Toàn bộ câu chuyện nằm trong case study Iventions, và thêm nhiều dự án đã ra mắt trong kho dự án.

Câu hỏi thường gặp

Nên dùng WordPress REST API hay WPGraphQL với Next.js?

Dùng REST API cho bài/trang tiêu chuẩn khi muốn không thêm plugin và URL dễ cache. Dùng WPGraphQL khi muốn query chính xác từng field cho mỗi component — lý tưởng cho build dạng block, nặng ACF, nhiều art direction, nơi mỗi section lấy đúng thứ nó cần.

Làm sao preview bản nháp WordPress trên frontend Next.js headless?

Thêm một route preview gọi draftMode().enable(), trỏ link preview của WordPress vào đó kèm một secret dùng chung, và trong Draft Mode fetch với cache: 'no-store' cộng header auth Application Password để editor thấy nội dung chưa đăng mới nhất.

Vì sao tôi bị lỗi CORS từ WordPress REST API?

Vì bạn đang fetch /wp-json từ trình duyệt. Dời lời gọi sang server — RSC, một route handler, hay generateMetadata — là server-to-server, không CORS gì cả. Tiện thể thông tin đăng nhập WordPress không bao giờ chạm client.

Headless WordPress có hại SEO hay hiệu năng không?

Không, nếu cache đúng. Với ISR của Next.js (revalidate + invalidate theo tag) bạn phục vụ HTML tĩnh cache trên CDN và làm mới khi đăng bài qua webhook — nhanh ngang site tĩnh hoàn toàn. Field SEO của Yoast hay Rank Math vẫn điều khiển metadata qua generateMetadata.

Có thể chuyển một site WordPress hiện có sang frontend headless mà không dời nội dung không?

Được — đó là điểm hấp dẫn chính. Nội dung, user và media vẫn nằm nguyên trong WordPress; bạn chỉ thay theme bằng một app Next.js đọc cùng API REST/GraphQL. Bạn thậm chí có thể chạy frontend mới trên một subdomain trong lúc cắt chuyển.

Cùng dựng nhé

Nếu bạn có một site WordPress mà theme đang kìm chân — hoặc bạn đang đặt làm một site mới và muốn nó nhanh, có chuyển động, và sửa được — tách nó thành một frontend Next.js chính là việc tôi làm với vai trò fullstack creative developer.


Viết bởi Hon Tran — creative developer, sáng lập hontran.dev, và giám khảo Awwwards. Hơn 11 năm xây dựng trải nghiệm web đoạt giải, ưu tiên hiệu năng (Next.js, GSAP, Three.js / WebGL) cho khách hàng toàn cầu. hontran.dev · Behance.

Bài liên quan