- Date
Headless WordPress SEO with Yoast and the Next.js App Router
This is a fresh take on SEO in Headless WordPress with Yoast, Next.js and WPGraphQL, which I wrote in November 2022 and have left up for reference. That post is not necessarily wrong, but it is dated: it was written for the Pages Router, Apollo Client, and
html-react-parser, and every one of those arguably has a better answer now. If you are working from it, the section below on what changed is the short version of the migration. Everything here is current as of WPGraphQL v2.23, Yoast SEO for WPGraphQL v5.1, and Next.js 16.
When you decouple WordPress, you give up the thing WordPress was quietly doing for you all along: rendering the <head>. Yoast SEO computes titles, meta descriptions, canonicals, Open Graph tags, and JSON-LD schema, and in a traditional theme it prints all of that for you. In a headless setup, none of it reaches the browser unless you go and ask for it.
This post covers how to get that data out of WordPress with WPGraphQL and put it back into a Next.js app.
What changed since the 2022 post
Three things, and together they make the original approach obsolete.
The App Router replaced next/head. Next.js now expects you to export a generateMetadata function from a route, and it builds the <head> for you. There is no <Head> component to render into.
Yoast’s WPGraphQL extension exposes structured fields, not just fullHead. The original post leaned on fullHead, a pre-rendered string of HTML, because that was the path of least resistance. The extension also exposes every piece of that metadata individually, which maps cleanly onto the object generateMetadata wants to return.
You no longer need an HTML parser in your dependency tree. html-react-parser existed in the original post purely to work around next/head not accepting raw HTML. With structured fields and generateMetadata, the whole problem goes away.
Prerequisites
- A WordPress install with WPGraphQL active
- A Next.js 15 or 16 app using the App Router
- Comfort reading a GraphQL query
Installing the plugins
Install and configure Yoast SEO from the plugin directory as you normally would. Yoast needs to be configured, not just activated, because the extension reads whatever Yoast computes.
Then install the WPGraphQL Yoast SEO Addon, which you can find from Plugins > Add New by searching for “WPGraphQL Yoast SEO”. If you manage plugins with Composer, it is on Packagist too:
composer require ashhitch/wp-graphql-yoast-seo
Development happens on GitHub if you want to read the source or file an issue.
One setting that catches people out: in Settings > General, your site address should point at your front end, not at WordPress. Yoast builds canonicals and Open Graph URLs from it, and if it points at the WordPress install you will publish canonicals that send search engines to your CMS.
What the seo field gives you
With both plugins active, every post, page, term, and user picks up an seo field:
query PostSeo($slug: ID!) { post(id: $slug, idType: SLUG) { title content seo { title metaDesc canonical metaRobotsNoindex metaRobotsNofollow opengraphTitle opengraphDescription opengraphUrl opengraphSiteName opengraphPublishedTime opengraphModifiedTime opengraphImage { altText sourceUrl } twitterTitle twitterDescription twitterImage { sourceUrl } breadcrumbs { text url } schema { raw } readingTime } }}
fullHead is still there and still works. I am not using it here, for reasons I will get to.
A small query helper
Before the metadata itself, here is the piece of plumbing every example below uses. Nothing exotic, just fetch with two things people routinely forget:
// lib/wp.ts
export async function wpQuery(
query: string,
variables?: Record
): Promise {
const res = await fetch(process.env.WPGRAPHQL_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables }),
next: { revalidate: 60 },
})
// Check this. A GraphQL endpoint that is down, rate limited, or behind a
// misconfigured proxy will happily hand you an HTML error page, and
// res.json() will throw a SyntaxError naming a line number in markup you
// never wrote. Ask me how I know.
if (!res.ok) {
throw new Error(`WPGraphQL responded ${res.status}`)
}
const json = await res.json()
// GraphQL returns 200 with an errors array. Without this, a failed query
// looks like a successful one that returned null.
if (json.errors?.length) {
throw new Error(json.errors[0].message)
}
return json.data as T
}
The next: { revalidate: 60 } option is what makes this cache. Tune it to taste, or swap it for cache: "no-store" on routes that must always be fresh.
Rendering metadata in the App Router
generateMetadata runs on the server, can be async, and returns an object that Next.js turns into tags. The mapping from Yoast’s fields is almost one to one:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { wpQuery } from "@/lib/wp"
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise {
const { slug } = await params
const data = await wpQuery(POST_SEO_QUERY, { slug })
const seo = data.post?.seo
if (!seo) return {}
return {
title: seo.title,
description: seo.metaDesc,
alternates: { canonical: seo.canonical },
robots: {
index: seo.metaRobotsNoindex !== "noindex",
follow: seo.metaRobotsNofollow !== "nofollow",
},
openGraph: {
title: seo.opengraphTitle ?? seo.title,
description: seo.opengraphDescription ?? seo.metaDesc,
url: seo.opengraphUrl,
siteName: seo.opengraphSiteName,
type: "article",
publishedTime: seo.opengraphPublishedTime,
modifiedTime: seo.opengraphModifiedTime,
images: seo.opengraphImage
? [{ url: seo.opengraphImage.sourceUrl, alt: seo.opengraphImage.altText }]
: [],
},
twitter: {
card: "summary_large_image",
title: seo.twitterTitle ?? seo.title,
description: seo.twitterDescription ?? seo.metaDesc,
images: seo.twitterImage ? [seo.twitterImage.sourceUrl] : [],
},
}
}
Two things worth knowing about that snippet.
params is a Promise in Next.js 15 and later. That trips up a lot of people migrating older code.
And metaRobotsNoindex is named misleadingly. It does not hold a boolean or the string "noindex" specifically, it holds whatever Yoast computed for the index directive, so the value is "index" on a normal post and "noindex" on an excluded one. metaRobotsNofollow works the same way, holding "follow" or "nofollow". Compare against the negative value as above rather than treating the field as a truthy flag, or every post on your site will come out noindex.
Next.js deduplicates fetch calls within a render, so calling wpQuery in both generateMetadata and the page component does not produce two requests, as long as the query and variables match.
JSON-LD schema
generateMetadata does not handle structured data, so render it yourself. Yoast hands you the finished graph as a string in seo.schema.raw:
export default async function PostPage({ params }) {
const { slug } = await params
const data = await wpQuery(POST_QUERY, { slug })
if (!data.post) notFound()
return (
<>
{data.post.seo?.schema?.raw && (
)}
{data.post.title}
>
)
}
schema.raw comes from your own WordPress install, so the trust question is the same one you already answered by rendering post.content the same way.
Why not fullHead anymore
fullHead is a convenience. It is also a string of HTML generated on a server that does not know your front end exists, so it carries whatever Yoast decided, including tags you may already be emitting from your layout. Rendering it in the App Router means either parsing it back into React elements or dropping it in raw, and both compete with the metadata Next.js is generating. You end up with two sources of truth for the <head> and duplicate tags when they disagree.
The structured fields give you the same data in a form you can compose with, override per route, and fall back on. That is worth more than the few lines it saves.
fullHead still has a legitimate use: a quick audit. Query it in GraphiQL when you want to see everything Yoast thinks it knows about a URL.
Sitemaps
Yoast generates sitemaps on the WordPress side, and they are good. The catch is that they live on your WordPress domain, and you want them served from your front end.
A Next.js rewrite handles it without any parsing:
// next.config.tsasync rewrites() { return [ { source: "/:path(.*sitemap.*\\.xml)", destination: `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/:path`, }, { source: "/:path(.*sitemap.*\\.xsl)", destination: `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/:path`, }, ]}
The original post used middleware plus an API route that fetched the XML and re-sent it. A rewrite does the same job at the edge with no code to maintain. Include the .xsl rule, or the sitemaps will render as an unstyled wall of XML.
For this to be correct, the URLs inside the sitemap have to be front-end URLs. That is the Settings > General change from earlier doing its job.
Wrapping up
The shape of the solution has not changed since 2022: WordPress computes the SEO data, WPGraphQL exposes it, the front end renders it. What changed is that both ends got better at it. Yoast’s extension gives you structured fields instead of a blob, and the App Router gives you a real metadata API instead of a component you race against.
If you are migrating from the 2022 post, the work is mostly deletion. Remove html-react-parser, remove the next/head usage, move the query into generateMetadata, and keep the sitemap idea while swapping the middleware for a rewrite.
Questions, or a better pattern than the one above? The WPGraphQL Discord is the place.