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 && (