Yoast data does not come with you. You have to carry it across yourself.

Someone on your team spent years writing those titles. They rewrote meta descriptions after watching click through rates, set canonicals on the duplicate pages, and picked a share image for every post that mattered. All of that lives in your WordPress database, and none of it moves when you rebuild the front end in Next.js.

Lose it and your rankings do not collapse on day one. They sag over a few weeks while you wonder what went wrong.

What Yoast actually stores

Yoast writes per post values into wp_postmeta. Each row is keyed by post ID with a meta key that starts _yoast_wpseo_. Site wide defaults, like your title template and the separator character, live in the wpseo_titles option instead.

Meta keyWhat it holdsWhere it goes in Next.js
_yoast_wpseo_titlePage title, often with variablestitle
_yoast_wpseo_metadescMeta descriptiondescription
_yoast_wpseo_canonicalCanonical URL, set only when overriddenalternates.canonical
_yoast_wpseo_meta-robots-noindex1 means noindex, 2 means indexrobots.index
_yoast_wpseo_meta-robots-nofollow1 means nofollowrobots.follow
_yoast_wpseo_opengraph-titleShare title, when it differsopenGraph.title
_yoast_wpseo_opengraph-imageShare image URLopenGraph.images
_yoast_wpseo_twitter-imageTwitter card image, when set separatelytwitter.images
The keys you will hit on almost every site. Yoast writes a row only when a value differs from the template, so most posts have fewer rows than you expect.

That last point catches people. If a post has no _yoast_wpseo_title row, it is not missing a title. It is using your site wide template, which might read something like %%title%% %%sep%% %%sitename%%. Read the raw meta table alone and you will conclude half your site has no titles.

Getting the data out

You have two routes. Query the database directly, or ask the REST API for the resolved output.

The direct query

SQL gives you the raw stored values. Use it when you want to audit what was overridden rather than what was rendered.

export-yoast.sqlsql
-- Every Yoast override, one row per postSELECT  p.ID,  p.post_name AS slug,  p.post_type,  MAX(CASE WHEN m.meta_key = '_yoast_wpseo_title'      THEN m.meta_value END) AS seo_title,  MAX(CASE WHEN m.meta_key = '_yoast_wpseo_metadesc'   THEN m.meta_value END) AS seo_desc,  MAX(CASE WHEN m.meta_key = '_yoast_wpseo_canonical'  THEN m.meta_value END) AS canonical,  MAX(CASE WHEN m.meta_key = '_yoast_wpseo_meta-robots-noindex' THEN m.meta_value END) AS noindexFROM wp_posts pLEFT JOIN wp_postmeta m ON m.post_id = p.IDWHERE p.post_status = 'publish'  AND p.post_type IN ('post', 'page')GROUP BY p.ID, p.post_name, p.post_typeORDER BY p.post_type, p.post_name;

The REST route, which is usually better

Since version 14, Yoast adds two fields to REST responses. yoast_head gives you a rendered HTML string. yoast_head_json gives you the same thing as a structured object, with every replacement variable already resolved and every site wide default already applied.

Use the JSON one. It saves you from reimplementing Yoast's template logic, which is harder than it looks and not worth your time.

terminalbash
# Pull the resolved SEO block for every published postcurl "https://cms.example.com/wp-json/wp/v2/posts?per_page=100&_fields=slug,yoast_head_json" # Look at one post first, so you know the shape you are mappingcurl "https://cms.example.com/wp-json/wp/v2/posts?slug=my-post&_fields=yoast_head_json" | jq
response, trimmedjson
{  "yoast_head_json": {    "title": "How we cut load time in half | Example Co",    "description": "A short account of the work, the numbers, and what we would do differently.",    "robots": { "index": "index", "follow": "follow" },    "canonical": "https://example.com/blog/cut-load-time",    "og_title": "How we cut load time in half",    "og_description": "A short account of the work and the numbers.",    "og_image": [{ "url": "https://example.com/uploads/cover.jpg", "width": 1200, "height": 630 }],    "twitter_card": "summary_large_image",    "schema": { "@context": "https://schema.org", "@graph": [] }  }}

Titles and descriptions

Next.js handles titles through the Metadata API. Set a template once in your root layout and each page fills in its own part.

app/layout.tsxtypescript
export const metadata: Metadata = {  metadataBase: new URL('https://example.com'),  title: {    // Matches the old Yoast pattern: %%title%% %%sep%% %%sitename%%    default: 'Example Co',    template: '%s | Example Co',  },};

For a page pulling from WordPress, resolve the metadata at build time. The important part is the fallback chain: use the Yoast override when it exists, fall back to the post title when it does not.

app/blog/[slug]/page.tsxtypescript
import type { Metadata } from 'next';import { getPostBySlug } from '@/lib/wp'; type Params = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Params): Promise<Metadata> {  const { slug } = await params;  const post = await getPostBySlug(slug);  if (!post) return { title: 'Not found' };   const seo = post.yoast_head_json ?? {};  const url = 'https://example.com/blog/' + slug;   // Yoast writes the full title including the site name. The layout template  // would append it a second time, so strip the suffix before using it.  const rawTitle = seo.title ?? post.title.rendered;  const title = rawTitle.replace(/ \| Example Co$/, '');   return {    title,    description: seo.description ?? stripTags(post.excerpt.rendered),    alternates: { canonical: seo.canonical ?? url },    robots: {      index: seo.robots?.index !== 'noindex',      follow: seo.robots?.follow !== 'nofollow',    },  };}

That title suffix problem bites nearly everyone. Yoast stores the finished string with the site name baked in. Your Next.js template adds it again. You end up with Post name | Example Co | Example Co across the whole site, and nobody notices until a client screenshots a search result.

Canonicals and robots rules

Canonicals need care. Yoast writes an absolute URL, and that URL points at your old domain structure. Copy it across unchanged and you tell Google your new pages are copies of pages that no longer exist.

Rewrite the host, keep the path:

lib/seo.tstypescript
const OLD_HOST = 'https://old.example.com';const NEW_HOST = 'https://example.com'; /** * Yoast canonicals are absolute and point at the WordPress install. Move the * host across but keep the path, so a genuine cross page canonical still * resolves to the right target on the new site. */export function rewriteCanonical(canonical: string | undefined, fallback: string) {  if (!canonical) return fallback;  if (canonical.startsWith(OLD_HOST)) {    return NEW_HOST + new URL(canonical).pathname;  }  // Points somewhere else entirely, so it was deliberate. Leave it alone.  return canonical;}

Robots rules are simpler but easy to invert. Yoast stores 1 for noindex and 2 for index in the raw meta, which reads backwards if you assume 1 means true. The JSON field avoids this by giving you the words index and noindex directly. Use the JSON field.

Open Graph and social cards

Yoast falls back through a chain for share images: the explicit Open Graph image, then the featured image, then a site wide default. Reproduce that chain or half your posts will share with a blank card.

lib/seo.tstypescript
type OgImage = { url: string; width?: number; height?: number }; export function shareImage(seo: YoastHead, post: WpPost): OgImage {  // 1. Explicit Open Graph image set in the Yoast panel  const explicit = seo.og_image?.[0];  if (explicit?.url) return explicit;   // 2. Featured image on the post  const featured = post._embedded?.['wp:featuredmedia']?.[0];  if (featured?.source_url) {    return {      url: featured.source_url,      width: featured.media_details?.width,      height: featured.media_details?.height,    };  }   // 3. Site default, so nothing ever shares without a card  return { url: 'https://example.com/og-default.webp', width: 1200, height: 630 };}
app/blog/[slug]/page.tsxtypescript
  const image = shareImage(seo, post);   return {    title,    description,    openGraph: {      type: 'article',      title: seo.og_title ?? title,      description: seo.og_description ?? description,      url,      publishedTime: post.date,      modifiedTime: post.modified,      images: [image],    },    twitter: {      card: seo.twitter_card ?? 'summary_large_image',      images: [image.url],    },  };

The schema Yoast was emitting

This is the part that gets forgotten. Yoast builds a JSON-LD graph on every page, connecting the article to its author, the site, the organisation and the breadcrumb trail. It sits in your source and you have probably never read it.

Delete it and you lose your breadcrumb display in search results, plus whatever author and organisation signals Google had been reading for years.

You can copy Yoast's graph across from yoast_head_json.schema. I would not. It carries WordPress specific node IDs and references to pages that will not exist after the move. Write a clean version instead. It takes twenty minutes and you end up understanding your own markup.

app/blog/[slug]/page.tsxtypescript
function schema(post: WpPost, url: string, description: string) {  return [    {      '@context': 'https://schema.org',      '@type': 'BlogPosting',      headline: post.title.rendered,      description,      url,      mainEntityOfPage: { '@type': 'WebPage', '@id': url },      datePublished: post.date,      dateModified: post.modified,      author: { '@type': 'Person', name: 'Author Name', url: 'https://example.com/about' },      publisher: { '@type': 'Organization', name: 'Example Co' },    },    {      '@context': 'https://schema.org',      '@type': 'BreadcrumbList',      itemListElement: [        { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com' },        { '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://example.com/blog' },        { '@type': 'ListItem', position: 3, name: post.title.rendered, item: url },      ],    },  ];}

Render each object in its own script tag. Google reads several blocks on one page without complaint, and keeping them separate makes each one easier to validate.

Checking nothing dropped

Do not eyeball this. Write a script that fetches both versions of every URL and compares the tags that matter.

scripts/diff-meta.mjsjavascript
import { writeFileSync } from 'node:fs'; const OLD = 'https://old.example.com';const NEW = 'https://staging.example.com';const paths = JSON.parse(await (await fetch(OLD + '/wp-json/wp/v2/posts?per_page=100&_fields=link'))  .text())  .map((p) => new URL(p.link).pathname); const pick = (html) => ({  title: html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim(),  description: html.match(/<meta name="description" content="([^"]*)"/i)?.[1],  canonical: html.match(/<link rel="canonical" href="([^"]*)"/i)?.[1],  ogImage: html.match(/<meta property="og:image" content="([^"]*)"/i)?.[1],  robots: html.match(/<meta name="robots" content="([^"]*)"/i)?.[1],}); const report = []; for (const path of paths) {  const [a, b] = await Promise.all([    fetch(OLD + path).then((r) => r.text()),    fetch(NEW + path).then((r) => r.text()),  ]);   const before = pick(a);  const after = pick(b);   for (const key of Object.keys(before)) {    // Flag anything that existed before and is now missing or different    if (before[key] && before[key] !== after[key]) {      report.push({ path, key, before: before[key], after: after[key] ?? null });    }  }} writeFileSync('meta-diff.json', JSON.stringify(report, null, 2));console.log(report.length + ' differences across ' + paths.length + ' pages');

Expect differences. Your titles may lose a suffix you removed on purpose, and canonicals will point at the new host. What you are hunting for is the empty aftervalue, because that means a tag vanished.

Run it against staging, fix what it finds, then run it again. Once the only differences left are ones you can explain out loud, you are ready to move.

This is the part of a migration I spend the most time on, and it is the part that decides whether traffic holds. If you would rather not do it yourself, this is work I take on, with the metadata diff run before anything goes live.

Related readingMoving a WordPress site to Next.js without losing your rankings

Metadata is one piece of a migration. The redirect map matters more, and getting it wrong costs you more traffic than any missing description ever will.

If your team wants to keep writing in WordPress after the move, you do not have to give up Yoast at all. Run WordPress headless, keep the plugin, and read yoast_head_json on every build.

Related readingKeep the WordPress editor, drop the WordPress front end

Resources