| name | wp-to-sanity-nextjs |
| description | Provides complete architecture and code for Evolution 301 (WordPress) to Next.js + Sanity integration. Invoke when setting up a new Next.js project receiving AI content, or fixing WP mock API, Vercel uploads, SEO rendering, and dark mode issues. |
Evolution 301 to Next.js + Sanity Complete Integration Architecture
This skill provides the comprehensive architecture, database schemas, API mocks, and frontend rendering techniques required to seamlessly integrate the Evolution 301 AI writing system (which strictly expects a WordPress REST API) with a modern Next.js App Router + Sanity CMS stack hosted on Vercel.
1. Client-Side (Evolution 301 / Docker) Configuration Requirements
If you see an HTTP 403 Forbidden, 404, or 405 error when testing the connection or writing articles, check these critical configurations:
- Target URL Configuration: Do NOT point the Evolution 301 client to your root domain (e.g.,
https://your-domain.com). You MUST point it to the API directory: https://your-domain.com/api.
- Common Trap: Ensure there are no accidental trailing characters like spaces or
) in the Evolution 301 site settings, which will cause immediate 404s or 403s.
- Basic Auth Credentials: Ensure the "įŽĄįč´Ļæˇ" (Username) and "åēį¨é´æį§éĨ" (Password) you enter in the Evolution 301 UI exactly match the
WP_MOCK_USERNAME and WP_MOCK_PASSWORD environment variables deployed on Vercel.
- Vercel WAF & Deployment Protection (The "403 Forbidden" Trap): Vercel's Managed Rulesets often block automated requests to WordPress paths (
/wp-json/...) returning a 403 Forbidden with x-vercel-mitigated: deny headers.
- Solution: Go to Vercel -> Security -> Web Application Firewall (WAF) -> Custom Rules. Add a rule: IF
User-Agent equals Evolution-SEO-Bot/1.0 (or Path starts with /api/wp-json/), THEN Bypass (or Allow). Also disable Password Protection for the environment.
- Image Compression / Size Limits: Vercel Serverless Functions have a strict 4.5MB payload limit. You MUST configure Evolution 301 to compress generated or fetched images to be strictly under ~4MB before uploading.
- AI Node & Concurrency (The "Planning Stuck / SSE" Trap): If the pipeline gets stuck in the Planning phase and the backend logs show
httpx_sse._exceptions.SSEError or JSON parsing errors, it is likely due to the overseas AI API node (e.g., custom proxy) returning JSON error responses instead of SSE streams under high concurrency.
- Solution: Lower the batch processing concurrency to
1 in Evolution 301 settings, or switch to a stable official API channel.
2. Core Architecture Strategy
Evolution 301 acts as a "client" pushing content to what it thinks is a WordPress server. We intercept this by creating a Mock WP REST API layer in Next.js (src/app/api/wp-json/wp/v2/...), which translates incoming requests and saves them directly to Sanity CMS.
â ī¸ Critical Dependency: Sanity API Token (Write Access)
For the Next.js API to successfully write drafts, update meta data, and upload images to Sanity, the SANITY_API_TOKEN environment variable configured in Vercel MUST have Editor or Contributor (Write) permissions.
If you accidentally generate a default Viewer (Read-only) token in the Sanity Dashboard, Evolution 301 will fail to write the post and you will see a 500 Internal Server Error (Insufficient permissions; permission "create" required) in your Vercel Edge logs.
3. Sanity CMS Schema (post.ts)
To support the data payload from Evolution 301 (including SEO plugins and raw HTML), your Sanity Post schema MUST include these specific fields:
import { defineField, defineType } from 'sanity'
export default defineType({
name: 'post',
title: 'Article',
type: 'document',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string' }),
defineField({ name: 'slug', title: 'Slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'htmlContent', title: 'Raw HTML Content', type: 'text', rows: 20 }),
defineField({ name: 'wordpressId', title: 'WordPress ID', type: 'string' }),
defineField({ name: 'seoTitle', title: 'SEO Title', type: 'string' }),
defineField({ name: 'seoDescription', title: 'SEO Description', type: 'text' }),
defineField({ name: 'mainImage', title: 'Cover Image', type: 'image' }),
]
})
4. The Mock WordPress REST API Layer
A. Authentication & Taxonomy Mocks (Crucial for AI Pre-flight Checks)
Evolution 301 checks the site's health before writing. You MUST return empty arrays or mock objects for these routes to prevent 404 Not Found errors that crash the AI pipeline.
src/app/api/wp-json/wp/v2/users/me/route.ts: Validate Basic Auth and return a mock admin object. This is what Evolution 301 pings when you click "æĩč¯čŋéæ§".
import { NextResponse } from 'next/server';
import { getCorsHeaders } from '../posts/route';
export async function OPTIONS() {
return NextResponse.json({}, { headers: getCorsHeaders() });
}
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
const user = process.env.WP_MOCK_USERNAME || '894825716@qq.com';
const pass = process.env.WP_MOCK_PASSWORD || 'Cylldjw99!';
const expectedToken = Buffer.from(\`\${user}:\${pass}\`).toString('base64');
if (!authHeader || authHeader !== \`Basic \${expectedToken}\`) {
// If credentials don't match, return 401 (Evolution 301 might show this as a connection failure)
return NextResponse.json({ message: "Unauthorized" }, { status: 401, headers: getCorsHeaders() });
}
return NextResponse.json({
id: 1,
name: "Admin",
description: "Mock Admin for Evolution 301 Integration",
avatar_urls: { "24": "", "48": "", "96": "" }
}, { status: 200, headers: getCorsHeaders() });
}
src/app/api/wp-json/wp/v2/categories/route.ts: return NextResponse.json([])
src/app/api/wp-json/wp/v2/tags/route.ts: return NextResponse.json([])
src/app/api/wp-json/wp/v2/plugins/route.ts: return NextResponse.json([]) (Bypasses strict SEO plugin checks).
B. Global CORS Headers (Required for all WP API routes)
export function getCorsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Content-Disposition, X-WP-Nonce, X-Requested-With, Accept',
'Access-Control-Expose-Headers': 'X-WP-Total, X-WP-TotalPages'
};
}
C. Media Upload Endpoint (/app/api/wp-json/wp/v2/media/route.ts)
Key Challenges Addressed:
- Vercel 4.5MB payload limit (images must be compressed before upload).
- Next.js
rewrites drop multipart/form-data. Solution: Client must send directly to /api/wp-json....
- WordPress clients use unpredictable field names (
file, async-upload). Safely extract files by checking for arrayBuffer.
import { NextResponse } from 'next/server';
import { writeClient } from '@/sanity/lib/write-client';
export async function POST(request: Request) {
try {
let file: File | null = null;
let buffer: Buffer | null = null;
let filename = \`upload-\${Date.now()}.jpg\`;
let altText = "AI Generated Image";
let titleFromForm = filename;
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
// Safely find the file regardless of the field name WP uses
let fileKey = Array.from(formData.keys()).find(k => {
const val = formData.get(k);
return val && typeof val === 'object' && 'arrayBuffer' in val;
});
file = fileKey ? formData.get(fileKey) as File : null;
if (file) {
filename = file.name || filename;
titleFromForm = filename;
buffer = Buffer.from(await file.arrayBuffer());
}
if (formData.has('alt_text')) altText = formData.get('alt_text') as string || altText;
if (formData.has('title')) titleFromForm = formData.get('title') as string || titleFromForm;
} else {
buffer = Buffer.from(await request.arrayBuffer());
}
if (!buffer) return NextResponse.json({ message: "No file found" }, { status: 400 });
const asset = await writeClient.assets.upload('image', buffer, { filename });
const numericId = Math.floor(Math.random() * 10000000);
await writeClient.patch(asset._id).set({
title: numericId.toString(),
description: altText,
altText: altText
}).commit();
return NextResponse.json({
id: numericId,
date: new Date().toISOString(),
slug: filename,
type: "attachment",
link: asset.url,
title: { rendered: titleFromForm },
source_url: asset.url,
}, { status: 201 });
} catch (error: any) {
return NextResponse.json({ message: "Upload failed", error: error.message }, { status: 500 });
}
}
D. Article Creation & Update Endpoint (/app/api/wp-json/wp/v2/posts/[id]/route.ts)
Key Challenges Addressed:
- Robust Slug Generation: Handles Chinese characters by converting them to hex to avoid invalid URL paths.
- SEO Extraction: Grabs meta data from RankMath, Yoast, and AIOSEO automatically.
- Evolution 301 Final Publish: Evolution 301 updates the final HTML, SEO, and status via a
POST or PUT to /posts/[id]. We must intercept this and perform a writeClient.patch on the Sanity document.
- Pre-Flight Draft Check (405 Method Not Allowed): Evolution 301 fetches the post via
GET before updating. A mock GET response is mandatory.
import { NextResponse } from 'next/server';
import { writeClient } from '@/sanity/lib/write-client';
import { client } from '@/sanity/lib/client';
import { getCorsHeaders } from '../route';
export async function OPTIONS() {
return new NextResponse(null, { status: 200, headers: getCorsHeaders() });
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return NextResponse.json({
id: parseInt(id),
date: new Date().toISOString(),
status: 'draft',
type: 'post',
title: { rendered: 'Draft Article' },
content: { rendered: '' }
}, { status: 200, headers: getCorsHeaders() });
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const titleText = typeof body.title === 'object' ? body.title.rendered : (body.title || undefined);
const contentHtml = typeof body.content === 'object' ? body.content.rendered : (body.content || undefined);
const excerpt = typeof body.excerpt === 'object' ? body.excerpt.rendered : (body.excerpt || undefined);
const { status, slug, featured_media, meta } = body;
const existingDoc = await client.fetch(\`*[_type == "post" && wordpressId == $id][0]\`, { id });
const finalSlug = slug || (titleText ? titleText.trim().toLowerCase()
.replace(/[\\u4e00-\\u9fa5]/g, (c: string) => c.charCodeAt(0).toString(16))
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 80) : undefined);
const seoTitle = meta?.rank_math_title || meta?.['_yoast_wpseo_title'] || meta?.['_aioseo_title'] || undefined;
const seoDescription = meta?.rank_math_description || meta?.['_yoast_wpseo_metadesc'] || meta?.['_aioseo_description'] || undefined;
let mainImageRef = undefined;
if (featured_media && featured_media > 0) {
try {
const latestAsset = await client.fetch(\`*[_type == "sanity.imageAsset"] | order(_createdAt desc)[0] { _id }\`);
if (latestAsset?._id) {
mainImageRef = { _type: 'image', asset: { _type: 'reference', _ref: latestAsset._id } };
}
} catch (e) {
console.warn('Cover image lookup failed', e);
}
}
if (existingDoc) {
// Update existing document
const patch = writeClient.patch(existingDoc._id);
if (titleText) patch.set({ title: titleText });
if (finalSlug) patch.set({ slug: { _type: 'slug', current: finalSlug } });
if (contentHtml) patch.set({ htmlContent: contentHtml });
if (excerpt || seoDescription) patch.set({ description: excerpt || seoDescription });
if (seoTitle) patch.set({ seoTitle });
if (seoDescription) patch.set({ seoDescription });
if (mainImageRef) patch.set({ mainImage: mainImageRef });
await patch.commit();
} else {
// Create new document if it doesn't exist
const sanityDoc = {
_type: 'post',
title: titleText || 'Untitled',
slug: finalSlug ? { _type: 'slug', current: finalSlug } : undefined,
htmlContent: contentHtml,
description: excerpt || seoDescription,
seoTitle: seoTitle,
seoDescription: seoDescription,
wordpressId: id,
publishedAt: new Date().toISOString(),
...(mainImageRef ? { mainImage: mainImageRef } : {}),
};
await writeClient.create(sanityDoc);
}
return NextResponse.json({
id: parseInt(id),
date: new Date().toISOString(),
slug: finalSlug || id,
status: status || 'publish',
type: 'post',
link: \`\${process.env.NEXT_PUBLIC_SITE_URL || 'https://your-domain.com'}/articles/\${finalSlug || id}\`,
title: { rendered: titleText || '' },
}, { status: 200, headers: getCorsHeaders() });
} catch (error) {
return NextResponse.json({ message: 'Post update failed' }, { status: 500, headers: getCorsHeaders() });
}
}
// Evolution 301 might use PUT or PATCH to update posts
export { POST as PUT, POST as PATCH };
5. Frontend Rendering & SEO Optimization
A. Rendering AI-Generated HTML (Dark Mode Safe)
AI models often generate HTML with hardcoded light-mode inline styles (e.g., background-color: white; color: #333). If your site has a Dark Mode, you must strip these using Regex before injecting into dangerouslySetInnerHTML:
<div dangerouslySetInnerHTML={{
__html: post.htmlContent
.replace(/background-color\s*:\s*(#f[0-9a-f]{5}|#e[0-9a-f]{5}|white|#fff)/gi, 'background-color:transparent')
.replace(/(?<![a-z-])color\s*:\s*(#[0-3][0-9a-f]{5}|black)/gi, 'color:inherit')
}} />
B. Dynamic Table of Contents (TOC)
Since the content is raw HTML, extract the TOC dynamically matching <h2> and <h3> tags:
export function extractTocFromMarkdown(html: string) {
const regex = /<h([2-3])[^>]*>(.*?)<\/h\1>/g;
const toc = [];
let match;
while ((match = regex.exec(html)) !== null) {
const level = parseInt(match[1]);
const text = match[2].replace(/<[^>]+>/g, '').trim();
const id = text.toLowerCase().replace(/\s+/g, '-');
toc.push({ level, text, id });
}
return toc;
}
C. Comprehensive SEO Metadata (generateMetadata)
Map the intercepted seoTitle and seoDescription to Next.js App Router metadata. Crucially, include explicit Twitter Cards:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getSanityPost(params.slug);
const metaTitle = post.seoTitle || post.title;
const metaDesc = post.seoDescription || post.description;
return {
title: metaTitle,
description: metaDesc,
openGraph: {
title: metaTitle,
description: metaDesc,
type: "article",
images: post.image ? [{ url: post.image }] : [],
},
twitter: {
card: "summary_large_image",
title: metaTitle,
description: metaDesc,
images: post.image ? [post.image] : [],
},
};
}
6. Next.js Configuration (next.config.ts)
- Whitelist Sanity CDN:
<Image> components will break if cdn.sanity.io isn't allowed.
- Global CORS & Rewrites:
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.sanity.io' },
],
},
async rewrites() {
return [
{ source: '/wp-json/:path*', destination: '/api/wp-json/:path*' },
];
},
async headers() {
return [
{
source: '/wp-json/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization, Content-Disposition, X-WP-Nonce, X-Requested-With, Accept' },
],
},
];
},
};
export default nextConfig;