Next.js App Router 无头 WordPress 实战:cms-wordpress 官方示例全解析
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文以 Next.js 官方示例 examples/cms-wordpress 为主体,完整拆解「用 WordPress 作为数据源、用 Next.js App Router 作为前端渲染层」的无头(Headless)架构落地方案。读完本文,你将能够独立完成 WordPress 侧的 WPGraphQL + JWT 插件配置、Next.js 侧的环境变量与代码生成配置,并理解草稿预览(Draft Mode)、按需缓存重验证(On Demand Revalidation)、中间件重定向与 SEO 自动化这几条关键链路的源码实现原理。
示例定位与核心功能
该示例展示如何构建一个功能丰富的 App Router 项目,以 WordPress 作为内容数据源。README 中声明的核心功能与其对应的源码位置如下:
| 功能 | 源码位置 | 作用 |
|---|---|---|
robots.ts | src/app/robots.ts | 自动抓取 WordPress 的 robots.txt 内容并在/robots.txt路由上重新解析、吐出 |
sitemap.ts | src/app/sitemap.ts | 通过自定义 REST 端点拉取全部路径,动态生成/sitemap.xml |
middleware.ts | src/middleware.ts | 检查每个请求路径在 WordPress Redirection 插件中的重定向记录,命中则执行跳转 |
[[...slug]]可选通配路由 | src/app/[[...slug]]/page.tsx | 渲染所有 WordPress 内容的兜底路由,先查询内容类型再渲染对应模板,不可删除 |
not-found.tsx | src/app/not-found.tsx | 动态 404 页:将其中的 WordPress 数据库 ID 调整为你自己 404 页面的 ID(slug 为404-not-found),即可让 404 页面在 CMS 中可编辑 |
codegen.ts | codegen.ts | 针对你的 WordPress 安装自动生成 GraphQL 的 TypeScript 类型 |
| Draft Mode 草稿预览 | src/app/api/preview/route.ts | 基于 WPGraphQL JWT Authentication 登录 + Next.js Draft Mode,实现无缝的草稿/预览 |
| 按需缓存重验证 | src/app/api/revalidate/route.ts | 配合一个只含两个文件的最小 WordPress 主题,在内容更新时调用 Next.js 的 revalidation |
快速开始
通过 create-next-app 初始化项目
使用create-next-app配合 npm、Yarn、pnpm 或 Bun 任一方式引导该示例:
npx create-next-app --example cms-wordpress cms-wordpress-appyarn create next-app --example cms-wordpress cms-wordpress-apppnpm create next-app --example cms-wordpress cms-wordpress-appbunx create-next-app --example cms-wordpress cms-wordpress-app本地运行的一个重要细节
查看 package.json 可以看到,dev与build脚本并不是直接执行next dev,而是先跑 Codegen:
"dev": "graphql-codegen --config codegen.ts && node ./add-ts-nocheck.js && next dev"也就是说,运行npm run dev时会自动根据NEXT_PUBLIC_WORDPRESS_API_URL指向的 WordPress 安装生成 TypeScript 类型(README 中的 NOTE 亦明确说明这一点)。codegen脚本单独暴露,方便你在 WordPress Schema 变更后手动重新生成类型。
WordPress 侧配置(10 个步骤)
README 给出了完整的 WordPress 安装配置流程,这是整个无头方案能跑起来的前提,必须按序完成:
- 在Settings → General中把
Site Address (URL)设置为前端 URL,例如本地开发时的https://localhost:3000。 - 确认Settings → Permalinks中 Permalinks 设置为
Post name。 - 在Settings → Reading中将
Sample page设为Static page。 - 创建一个名为
404 not found的新页面,确保其 slug 为404-not-found。 - 安装并激活以下插件:
- Add WPGraphQL SEO
- Classic Editor
- Redirection
- WPGraphQL
- WPGraphQL JWT Authentication
- Yoast SEO
- Advanced Custom Fields PRO(可选)
- WPGraphQL for ACF(可选)
- 完成 Redirection 插件的首次安装,建议开启变更监控(monitor of changes)。
- 配置 Yoast SEO:
- 在 Yoast SEO → Settings 下禁用 XML Sitemaps(因为 sitemap 改由 Next.js 生成);
- 如果你在安装 Yoast 之前修改过
Site Address (URL),它在更改 permalink 后会提示运行 optimize SEO data,照做即可; - 在 Yoast SEO → Tools → File Editor 下生成 robots.txt 文件;
- 将 robots.txt 中的 sitemap 引用从
wp-sitemap.xml改为sitemap.xml。
- 在GraphQL → Settings下开启
Enable Public Introspection(Codegen 依赖内省接口拉取 Schema)。 - 在
wp-config.php中添加以下常量:
define('HEADLESS_SECRET', 'INSERT_RANDOM_SECRET_KEY'); define('HEADLESS_URL', 'INSERT_LOCAL_DEVELOPMENT_URL'); // http://localhost:3000 for local development define('GRAPHQL_JWT_AUTH_SECRET_KEY', 'INSERT_RANDOM_SECRET_KEY'); define('GRAPHQL_JWT_AUTH_CORS_ENABLE', true);- 创建一个只含两个文件的最小自定义 WordPress 主题:
style.css与functions.php(完整functions.php见文末WordPress 主题 functions.php一节)。
Next.js 侧配置
克隆仓库(或通过create-next-app初始化)后:
- 执行
npm install安装依赖; - 在项目根目录创建
.env文件并添加以下变量:
| 名称 | 取值 | 示例 | 说明 |
|---|---|---|---|
NEXT_PUBLIC_BASE_URL | 前端基础 URL | http://localhost:3000 | 用于生成 sitemap、重定向等 |
NEXT_PUBLIC_WORDPRESS_API_URL | WordPress 安装的基础 URL | http://wp-domain.com | 请求 WordPress 数据时使用 |
NEXT_PUBLIC_WORDPRESS_API_HOSTNAME | 不带协议的 WordPress 主机名 | wp-domain.com | 用于动态填充 next.config 的 images remotePatterns |
HEADLESS_SECRET | 与wp-config.php中相同的随机密钥 | INSERT_RANDOM_SECRET_KEY | 用于前后端之间的公共校验 |
WP_USER | 有效的 WordPress 用户名 | username | 专门用于与 WordPress 交互的系统账号用户名 |
WP_APP_PASS | 应用密码(Application Password) | 1234 5678 abcd efgh | 为WP_USER生成的应用密码 |
注意:
WP_USER与WP_APP_PASS是预览(preview)与重定向(redirection)功能正常工作所必需的关键变量。
- 调整 not-found.tsx 中的 WordPress 页面 ID(示例默认值为
501),使其匹配你自己 404 页面的databaseId:
const notFoundPageWordPressId = 501;- 执行
npm run dev,即可开始构建基于 WordPress 的应用(类型生成会随 dev 脚本自动完成)。
此外,next.config.js 利用上表中的主机名环境变量动态配置图片远程加载白名单,这正是NEXT_PUBLIC_WORDPRESS_API_HOSTNAME存在的意义:
const nextConfig = { trailingSlash: true, images: { remotePatterns: [ { protocol: "http", hostname: process.env.NEXT_PUBLIC_WORDPRESS_API_HOSTNAME, port: "", }, ], }, };trailingSlash: true也是有意为之的——WordPress 的 permalink 天然带尾斜杠,与重定向匹配、sitemap 生成的行为保持一致。
路由设计:可选通配段与模板路由
README 的「Template handling」章节说明:示例使用Optional Catch-all Segment([[...slug]])承接所有 WordPress 内容,渲染时先向 GraphQL 提问「这条路由是什么类型的内容」,再取对应模板;每个模板可以拥有自己独立的查询来获取特定内容。
page.tsx 的实现印证了这一点:
export default async function Page({ params }: Props) { const slug = nextSlugToWpSlug(params.slug); const isPreview = slug.includes("preview"); const { contentNode } = await fetchGraphQL<{ contentNode: ContentNode }>( print(ContentInfoQuery), { slug: isPreview ? slug.split("preview/")[1] : slug, idType: isPreview ? "DATABASE_ID" : "URI", }, ); if (!contentNode) return notFound(); switch (contentNode.contentTypeName) { case "page": return <PageTemplate node={contentNode} />; case "post": return <PostTemplate node={contentNode} />; default: return <p>{contentNode.contentTypeName} not implemented</p>; } }几个值得注意的实现细节:
- 探针查询 ContentInfoQuery.ts 只取
contentTypeName、databaseId、status、uri四个字段,成本极低,先「定类型」再「取内容」,避免每次请求都拉全量大查询; - nextSlugToWpSlug.ts 负责把 Next.js 传入的
slug(可能是数组)归一化为 WordPress URI,例如["about","us"]→about/us; - 预览路由
/preview/{id}会被识别为「假路由」:slug含preview时改用DATABASE_ID而非URI查询。这是因为草稿状态的 post 没有真实 slug; - 文件顶部还导出
generateStaticParams()返回空数组,表示这些路由不做静态预渲染。
SEO:Yoast 对象 → generateMetadata
README「SEO」章节说明:SEO 数据由 WordPress 中的 Yoast SEO 插件管理,每个路由都会请求 Yoast 的 SEO 对象,并解析到动态generateMetadata()中。在 page.tsx 中可以看到完整链路:SeoQuery取出contentNode.seo,经 utils/seoData.ts 的setSeoData转换后,再拼接基于NEXT_PUBLIC_BASE_URL的 canonical 链接返回给 Next.js 的Metadata类型。404 页面同样走这套流程(见 not-found.tsx),canonical 固定指向/404-not-found/。
robots.ts 与 sitemap.ts:SEO 的自动化
robots.ts
robots.ts 在运行时直接fetchWordPress 的/robots.txt原文(cache: "no-store"),逐行解析出User-agent、Allow、Disallow、Sitemap字段后,转译为 Next.js 的MetadataRoute.Robots结构返回。也就是说 WordPress(Yoast)里维护的 robots 规则会自动「镜像」到前端域名下,无需二次维护。
sitemap.ts
sitemap.ts 的生成逻辑分三步,全部依赖 WordPress 主题中注册的sitemap/v1REST 端点(对应functions.php的wsra_*系列函数):
GET /wp-json/sitemap/v1/totalpages获取各内容类型的总数;- 对每个类型按
perPage = 50计算总页数,并发请求/wp-json/sitemap/v1/posts?pageNo=&postType=&perPage=分页拉取 URL 与lastModified; - 汇总后拼上
NEXT_PUBLIC_BASE_URL前缀输出为MetadataRoute.Sitemap。
两者都声明export const revalidate = 0,即每次访问都回源 WordPress,保证站点地图与 robots 规则始终与 CMS 状态一致。
中间件:把 WordPress Redirection 插件的能力接到前端
README「Redirection setup」章节说明:示例支持 WordPress 的 Redirection 插件,必须配置WP_USER与WP_APP_PASS,这样重定向就可以完全在 WordPress CMS 里管理。
middleware.ts 的完整链路是:
- 若缺少
WP_USER/WP_APP_PASS直接放行(NextResponse.next()),保证未配置时不破坏站点; - 用
user:applicationPassword做 Basic 认证,调用 Redirection 插件的 REST 端点/wp-json/redirection/v1/redirect/,按「去尾斜杠后的路径」精确匹配(filterBy[url-match]=plain); - 命中记录后,用
NEXT_PUBLIC_BASE_URL拼接目标地址,并根据原状态码做语义保持的转换:301 → 308,302 → 307(保留请求方法与体的重定向码):
return NextResponse.redirect(newUrl, { status: redirect.action_code === 301 ? 308 : 307, });草稿 / 预览模式:Draft Mode + WPGraphQL JWT
README「Draft / Preview support」章节解释了预览机制:在 api/preview/route.ts 中启用draftMode时,路由会用WP_USER+WP_APP_PASS登录 WordPress,以认证用户身份请求 GraphQL,从而让草稿与预览可用;草稿状态的文章没有真实 slug,因此示例重定向到「假路由」/preview/${id},用 ID 直接取数。
对照 route.ts 源码,完整时序为:
- 密钥校验:
?secret=必须等于HEADLESS_SECRET且带id参数,否则 401。这个 URL 正是 WordPress 后台预览按钮经过set_headless_preview_link过滤器改写后生成的(见functions.php中add_query_arg(['secret' => HEADLESS_SECRET, 'id' => $post->ID], .../api/preview)); - GraphQL 登录:执行
LoginUsermutation 换取 JWTauthToken(依赖 WPGraphQL JWT Authentication 插件); - 开启 Draft Mode:
draftMode().enable(),此后该请求链路中的页面组件都能感知预览状态; - 按 ID 查内容节点:携带
Authorization: Bearer ${authToken}请求contentNode(id: $id, idType: DATABASE_ID),拿到status与uri; - 智能跳转:
status === "draft"时跳/preview/${databaseId},否则跳真实uri,并通过Set-Cookie: wp_jwt=...把 JWT 存入 Cookie。
Cookie 的消费方是 utils/fetchGraphQL.ts:它读取draftMode()状态,预览开启时从 Cookie 取wp_jwt组装 Bearer 头,并把请求缓存策略切为no-cache;退出预览则由 api/exit-preview/route.ts 调用draftMode().disable()并清空wp_jwtCookie,再按?path=跳回原页面。
按需缓存重验证(On Demand Revalidation)
README「Cache Revalidation」章节的机制是:所有 GraphQL 请求统一打上缓存标签wordpress;WordPress 中任何内容更新都会调用前端的/api/revalidate路由,重验证wordpress标签——既保证内容始终最新,又只在实际有更新时才回源。
三个文件共同构成这条链路:
- 打标签:fetchGraphQL.ts 中每次
fetch都附带next: { tags: ["wordpress"] },预览态时改为no-cache; - 接收端:revalidate/route.ts 处理
PUT请求,先校验请求头X-Headless-Secret-Key是否等于HEADLESS_SECRET(不符返回 401),然后对请求体中的paths逐条调用revalidatePath、对tags逐条调用revalidateTag,最后返回本次重验证的结果; - 触发端:WordPress 主题中通过
transition_post_status钩子在文章状态变更时向$frontendUrl/api/revalidate/发 PUT 请求,body 为{"tags": ["wordpress"]},并跳过自动保存(DOING_AUTOSAVE)、计划任务(DOING_CRON)、纯草稿状态迁移与inherit状态。
GraphQL Codegen 与 TypeScript 类型
README「GraphQL and typescript types」章节说明:类型由 Codegen 从 WordPress 提供的 Schema 生成。codegen.ts 的配置要点:
schema指向NEXT_PUBLIC_WORDPRESS_API_URL+/graphql,即 WordPress 的 GraphQL 端点(依赖第 8 步开启的 Public Introspection);generates有两个产物:src/gql/下用clientpreset 生成各查询的类型,src/gql/schema.gql用schema-ast插件落盘完整 Schema AST。
为 GraphQL 查询开启自动补全
README 建议安装 VS Code 的 "Apollo GraphQL" 扩展,并在next.config.js旁创建apollo.config.js:
module.exports = { client: { service: { name: "WordPress", localSchemaFile: "./src/gql/schema.gql", }, }, };注意这里引用的./src/gql/schema.gql正是 Codegen 生成的产物——先跑一次npm run codegen,IDE 的查询补全即可工作。
Advanced Custom Fields PRO(可选但推荐)
README 推荐用 ACF Pro 的Flexible Content数据类型来构建页面内容:它既能在编辑后台提供「块构建器」式的编辑体验,又能让所有内容自动完成类型生成、以结构化数据送达前端。相比之下,默认 Gutenberg 编辑器会返回大量 HTML,损失了「GraphQL + 类型生成」的大部分优势。
WordPress 主题 functions.php
README 说明,最小主题的functions.php实现了对 Next.js 集成至关重要的四项能力:
- 注册主导航菜单(供前端的
Navigation组件抓取); - 将预览链接与 REST 链接重写为指向前端域名而非 WordPress 安装地址;
- 在每次更新文章时实现缓存标签重验证;
- 为 sitemap 生成实现 REST 端点。
完整代码如下(README 原文收录,建议原样放入主题目录):
<?php /** * Registers new menus * * @return void */ add_action('init', 'register_new_menu'); function register_new_menu() { register_nav_menus( array( 'primary-menu' => __('Primary menu') ) ); } /** * Changes the REST API root URL to use the home URL as the base. * * @param string $url The complete URL including scheme and path. * @return string The REST API root URL. */ add_filter('rest_url', 'home_url_as_api_url'); function home_url_as_api_url($url) { $url = str_replace(home_url(), site_url(), $url); return $url; } /** * Customize the preview button in the WordPress admin. * * This function modifies the preview link for a post to point to a headless client setup. * * @param string $link Original WordPress preview link. * @param WP_Post $post Current post object. * @return string Modified headless preview link. */ add_filter( 'preview_post_link', 'set_headless_preview_link', 10, 2 ); function set_headless_preview_link( string $link, WP_Post $post ): string { // Set the front-end preview route. $frontendUrl = HEADLESS_URL; // Update the preview link in WordPress. return add_query_arg( [ 'secret' => HEADLESS_SECRET, 'id' => $post->ID, ], esc_url_raw( esc_url_raw( "$frontendUrl/api/preview" )) ); } add_filter( 'rest_prepare_page', 'set_headless_rest_preview_link', 10, 2 ); add_filter( 'rest_prepare_post', 'set_headless_rest_preview_link' , 10, 2 ); function set_headless_rest_preview_link( WP_REST_Response $response, WP_Post $post ): WP_REST_Response { // Check if the post status is 'draft' and set the preview link accordingly. if ( 'draft' === $post->post_status ) { $response->data['link'] = get_preview_post_link( $post ); return $response; } // For published posts, modify the permalink to point to the frontend. if ( 'publish' === $post->post_status ) { // Get the post permalink. $permalink = get_permalink( $post ); // Check if the permalink contains the site URL. if ( false !== stristr( $permalink, get_site_url() ) ) { $frontendUrl = HEADLESS_URL; // Replace the site URL with the frontend URL. $response->data['link'] = str_ireplace( get_site_url(), $frontendUrl, $permalink ); } } return $response; } /** * Adds the headless_revalidate function to the save_post action hook. * This function makes a PUT request to the headless site' api/revalidate endpoint with JSON body: paths = ['/path/to/page', '/path/to/another/page'] * Requires HEADLESS_URL and HEADLESS_SECRET to be defined in wp-config.php * * @param int $post_ID The ID of the post being saved. * @return void */ add_action('transition_post_status', 'headless_revalidate', 10, 3); function headless_revalidate(string $new_status, string $old_status, object $post ): void { if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return; } // Ignore drafts and inherited posts. if ( ( 'draft' === $new_status && 'draft' === $old_status ) || 'inherit' === $new_status ) { return; } $frontendUrl = HEADLESS_URL; $headlessSecret = HEADLESS_SECRET; $data = json_encode([ 'tags' => ['wordpress'], ]); $response = wp_remote_request("$frontendUrl/api/revalidate/", [ 'method' => 'PUT', 'body' => $data, 'headers' => [ 'X-Headless-Secret-Key' => $headlessSecret, 'Content-Type' => 'application/json', ], ]); // Check if the request was successful if (is_wp_error($response)) { // Handle error error_log($response->get_error_message()); } } function wsra_get_user_inputs() { $pageNo = sprintf("%d", $_GET['pageNo']); $perPage = sprintf("%d", $_GET['perPage']); // Check for array key taxonomyType if (array_key_exists('taxonomyType', $_GET)) { $taxonomy = $_GET['taxonomyType']; } else { $taxonomy = 'category'; } $postType = $_GET['postType']; $paged = $pageNo ? $pageNo : 1; $perPage = $perPage ? $perPage : 100; $offset = ($paged - 1) * $perPage; $args = array( 'number' => $perPage, 'offset' => $offset, ); $postArgs = array( 'posts_per_page' => $perPage, 'post_type' => strval($postType ? $postType : 'post'), 'paged' => $paged, ); return [$args, $postArgs, $taxonomy]; } function wsra_generate_author_api() { [$args] = wsra_get_user_inputs(); $author_urls = array(); $authors = get_users($args); foreach ($authors as $author) { $fullUrl = esc_url(get_author_posts_url($author->ID)); $url = str_replace(home_url(), '', $fullUrl); $tempArray = [ 'url' => $url, ]; array_push($author_urls, $tempArray); } return array_merge($author_urls); } function wsra_generate_taxonomy_api() { [$args,, $taxonomy] = wsra_get_user_inputs(); $taxonomy_urls = array(); $taxonomys = $taxonomy == 'tag' ? get_tags($args) : get_categories($args); foreach ($taxonomys as $taxonomy) { $fullUrl = esc_url(get_category_link($taxonomy->term_id)); $url = str_replace(home_url(), '', $fullUrl); $tempArray = [ 'url' => $url, ]; array_push($taxonomy_urls, $tempArray); } return array_merge($taxonomy_urls); } function wsra_generate_posts_api() { [, $postArgs] = wsra_get_user_inputs(); $postUrls = array(); $query = new WP_Query($postArgs); while ($query->have_posts()) { $query->the_post(); $uri = str_replace(home_url(), '', get_permalink()); $tempArray = [ 'url' => $uri, 'post_modified_date' => get_the_modified_date(), ]; array_push($postUrls, $tempArray); } wp_reset_postdata(); return array_merge($postUrls); } function wsra_generate_totalpages_api() { $args = array( 'exclude_from_search' => false ); $argsTwo = array( 'publicly_queryable' => true ); $post_types = get_post_types($args, 'names'); $post_typesTwo = get_post_types($argsTwo, 'names'); $post_types = array_merge($post_types, $post_typesTwo); unset($post_types['attachment']); $defaultArray = [ 'category' => count(get_categories()), 'tag' => count(get_tags()), 'user' => (int)count_users()['total_users'], ]; $tempValueHolder = array(); foreach ($post_types as $postType) { $tempValueHolder[$postType] = (int)wp_count_posts($postType)->publish; } return array_merge($defaultArray, $tempValueHolder); } add_action('rest_api_init', function () { register_rest_route('sitemap/v1', '/posts', array( 'methods' => 'GET', 'callback' => 'wsra_generate_posts_api', )); }); add_action('rest_api_init', function () { register_rest_route('sitemap/v1', '/taxonomy', array( 'methods' => 'GET', 'callback' => 'wsra_generate_taxonomy_api', )); }); add_action('rest_api_init', function () { register_rest_route('sitemap/v1', '/author', array( 'methods' => 'GET', 'callback' => 'wsra_generate_author_api', )); }); add_action('rest_api_init', function () { register_rest_route('sitemap/v1', '/totalpages', array( 'methods' => 'GET', 'callback' => 'wsra_generate_totalpages_api', )); });从源码结构看,functions.php中注册的四个sitemap/v1REST 端点(/posts、/taxonomy、/author、/totalpages)与 sitemap.ts 的请求一一对应,其中当前示例实际消费了/totalpages与/posts两个端点;/taxonomy与/author端点是为分类、标签、作者归档页预留的扩展位。
文件夹结构
README 描述的目录约定与实际src/目录完全对应:
app:应用的路由与页面——src/app 下含[[...slug]]/page.tsx、api/preview、api/exit-preview、api/revalidate、robots.ts、sitemap.ts、not-found.tsx等;assets:存放变量等实用样式(本示例中以globals.css与 CSS Modules 形式存在);components:应用组件——src/components 下分Globals(Navigation、PreviewNotice)与Templates(Page、Post 两套模板,各自携带独立的查询文件);gql:GraphQL CodeGen 自动生成的类型(src/gql/,运行 dev/build 时生成);queries:可复用的 GraphQL 数据请求——src/queries/general 下的ContentInfoQuery.ts与SeoQuery.ts;utils:跨应用使用的工具函数——src/utils 下的fetchGraphQL.ts、nextSlugToWpSlug.ts、seoData.ts。
小结:这套无头架构的分工
综合以上各节,该示例的架构分工可以归纳为:
- WordPress 负责「内容与元数据管理」:文章/页面编辑、Yoast SEO、Redirection 重定向、应用密码认证、JWT 签发;
- Next.js 负责「渲染与边缘能力」:App Router 的可选通配路由 + 内容类型模板分发、
generateMetadata输出 SEO、middleware 拦截重定向、revalidateTag按需回源、robots.ts/sitemap.ts动态 SEO 文件; - 两端通过三根「线缆」连接:GraphQL(数据)、
HEADLESS_SECRET(重验证与预览鉴权)、WP_USER+WP_APP_PASS应用密码(认证登录与 Redirection API 访问)。
如果你想继续参考同类集成,仓库的 examples 目录下还收录了 AgilityCMS、Builder.io、Contentful、Ghost、Prismic、Sanity 等多个 CMS 官方示例,可以横向对比不同数据源的接入方式;而本示例独有的「JWT 草稿预览 + Redirection 中间件 + 标签级重验证」组合,是 WordPress 无头场景下较为完整的工程化参考实现。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考