DEV Community

Cover image for One Object, Three Outputs: Keeping og:*, twitter:* and JSON-LD in Sync in Next.js
Abid Ali
Abid Ali

Posted on

One Object, Three Outputs: Keeping og:*, twitter:* and JSON-LD in Sync in Next.js

Open a few production pages and compare three things in the <head>: the meta description, og:description, and the description inside the JSON-LD block. On a lot of sites they say three slightly different things. The meta description was written at launch, the Open Graph text was fixed after someone complained about a LinkedIn card, and the JSON-LD was pasted from a schema generator and never touched again.

Nobody sees all three at once, so the drift goes unnoticed. Search engines read one set, chat and social apps read another, and the same page ends up introducing itself in different words depending on where someone meets it.

This post shows the setup I use on Next.js projects to stop that: one typed object per page, two small functions that turn it into Metadata and JSON-LD, and a script that catches drift on the live HTML.

Who reads what

It helps to be clear about the consumers first, because they are not interchangeable:

  • Open Graph tags (og:title, og:description, og:image, og:url) are what most chat and social apps read when they build a link preview card.
  • twitter:* tags are read by X. When they are missing, X can fall back to Open Graph for most fields, but the card layout comes from twitter:card.
  • JSON-LD (<script type="application/ld+json">) is structured data. Search engines use it to understand the page and, in some cases, for rich results. As far as I've seen, link preview cards in chat apps do not read it.
  • <title> and <meta name="description"> are the classic search snippet inputs and the browser tab.

So there is no single tag that "wins". Each consumer reads its own set, which is exactly why they need to come from one place.

Step 1: one object per page

I describe each page once, in a shape that has nothing to do with any particular output format:

// lib/seo/types.ts
export type PageSeo = {
  path: string;              // "/blog/head-drift"
  title: string;             // headline people see
  description: string;       // one or two sentences
  image: { url: string; width: number; height: number; alt: string };
  type: "website" | "article";
  publishedAt?: string;      // ISO date, articles only
  updatedAt?: string;
  author?: { name: string; url: string };
};

export const SITE = {
  name: "Field Notes",
  url: "https://example.com",
  twitter: "@fieldnotes",
} as const;
Enter fullscreen mode Exit fullscreen mode

Where the object comes from doesn't matter: a CMS, MDX front matter, a database row. What matters is that every head tag is derived from it and nothing is typed by hand twice.

Step 2: PageSeo to Next.js Metadata

// lib/seo/to-metadata.ts
import type { Metadata } from "next";
import { PageSeo, SITE } from "./types";

export function toMetadata(seo: PageSeo): Metadata {
  const url = new URL(seo.path, SITE.url).toString();
  return {
    metadataBase: new URL(SITE.url),
    title: seo.title,
    description: seo.description,
    alternates: { canonical: url },
    openGraph: {
      type: seo.type,
      url,
      siteName: SITE.name,
      title: seo.title,
      description: seo.description,
      images: [{ ...seo.image }],
      ...(seo.type === "article" && {
        publishedTime: seo.publishedAt,
        modifiedTime: seo.updatedAt ?? seo.publishedAt,
        authors: seo.author ? [seo.author.url] : undefined,
      }),
    },
    twitter: {
      card: "summary_large_image",
      site: SITE.twitter,
      title: seo.title,
      description: seo.description,
      images: [{ url: seo.image.url, alt: seo.image.alt }],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Two details worth calling out. alternates.canonical and openGraph.url are built from the same url variable, so the canonical link and og:url can't disagree. And the Twitter fields are filled explicitly instead of relying on fallbacks, so the card on X uses the same headline even if X's fallback behaviour changes.

Step 3: PageSeo to JSON-LD

// lib/seo/to-jsonld.ts
import { PageSeo, SITE } from "./types";

export function toJsonLd(seo: PageSeo) {
  const url = new URL(seo.path, SITE.url).toString();
  if (seo.type !== "article") {
    return {
      "@context": "https://schema.org",
      "@type": "WebPage",
      name: seo.title,
      description: seo.description,
      url,
    };
  }
  return {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: seo.title,
    description: seo.description,
    image: [seo.image.url],
    datePublished: seo.publishedAt,
    dateModified: seo.updatedAt ?? seo.publishedAt,
    author: seo.author && { "@type": "Person", name: seo.author.name, url: seo.author.url },
    publisher: { "@type": "Organization", name: SITE.name, url: SITE.url },
    mainEntityOfPage: url,
  };
}
Enter fullscreen mode Exit fullscreen mode

And a tiny component to render it. The replace matters: JSON.stringify does not escape <, so a title containing </script> could close the tag early. Escaping it as \u003c keeps the JSON valid and the HTML safe.

// components/json-ld.tsx
export function JsonLd({ data }: { data: object }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: wire it into a route

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, type Post } from "@/lib/posts";
import { toMetadata } from "@/lib/seo/to-metadata";
import { toJsonLd } from "@/lib/seo/to-jsonld";
import { JsonLd } from "@/components/json-ld";
import type { PageSeo } from "@/lib/seo/types";

type Props = { params: Promise<{ slug: string }> };

function seoFor(post: Post): PageSeo {
  return {
    path: `/blog/${post.slug}`,
    title: post.title,
    description: post.summary,
    image: { url: post.cover.url, width: 1200, height: 630, alt: post.cover.alt },
    type: "article",
    publishedAt: post.publishedAt,
    updatedAt: post.updatedAt,
    author: { name: "Abid Ali", url: "https://example.com/about" },
  };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost((await params).slug);
  if (!post) return {};
  return toMetadata(seoFor(post));
}

export default async function Page({ params }: Props) {
  const post = await getPost((await params).slug);
  if (!post) notFound();
  return (
    <article>
      <JsonLd data={toJsonLd(seoFor(post))} />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

getPost runs twice here, once for metadata and once for the page. If it's a fetch, Next.js deduplicates identical requests during a render. If it's a database call, wrap it in React's cache() so you don't hit the database twice.

Step 5: catch drift on the live page

The types keep new code honest. They don't help with the page that someone patched by hand, the plugin that injects a second description, or the CDN serving an old build. For that I check the HTML that actually goes out.

This script has no dependencies and runs on Node 18 or newer. It reads the meta tags and any JSON-LD blocks, then reports pairs that disagree:

// scripts/check-head-drift.mjs
// Usage: node scripts/check-head-drift.mjs <url> [<url> ...]
const decode = (s) =>
  s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'")
   .replace(/&lt;/g, "<").replace(/&gt;/g, ">");

function readMeta(html) {
  const out = {};
  for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
    const key = tag.match(/\b(?:property|name)=["']([^"']+)["']/i)?.[1];
    const content = tag.match(/\bcontent=["']([^"']*)["']/i)?.[1];
    if (key && content !== undefined && !(key in out)) out[key.toLowerCase()] = decode(content);
  }
  return out;
}

function readJsonLd(html) {
  const blocks = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)];
  const nodes = [];
  for (const [, raw] of blocks) {
    try {
      const data = JSON.parse(raw);
      nodes.push(...(Array.isArray(data) ? data : data["@graph"] ?? [data]));
    } catch {
      nodes.push({ __invalid: true });
    }
  }
  return nodes;
}

const firstImage = (img) =>
  typeof img === "string" ? img : Array.isArray(img) ? firstImage(img[0]) : img?.url;

async function check(url) {
  const res = await fetch(url, { redirect: "follow" });
  const html = await res.text();
  const meta = readMeta(html);
  const ld = readJsonLd(html);
  const problems = [];

  if (ld.some((n) => n.__invalid)) problems.push("a JSON-LD block is not valid JSON");
  const article = ld.find((n) => /Article|BlogPosting|NewsArticle/.test(String(n["@type"])));

  const pairs = [
    ["og:title", meta["og:title"], "twitter:title", meta["twitter:title"]],
    ["og:description", meta["og:description"], "description", meta["description"]],
    ["og:image", meta["og:image"], "twitter:image", meta["twitter:image"]],
  ];
  if (article) {
    pairs.push(
      ["og:title", meta["og:title"], "JSON-LD headline", article.headline],
      ["og:image", meta["og:image"], "JSON-LD image", firstImage(article.image)],
    );
  }
  for (const [aName, a, bName, b] of pairs) {
    if (a && b && a.trim() !== String(b).trim()) {
      problems.push(`${aName} != ${bName}\n    "${a}"\n    "${b}"`);
    }
  }
  console.log(`${res.status} ${url}${article ? "" : "  (no Article JSON-LD found)"}`);
  for (const p of problems) console.log("  - " + p);
  return problems.length;
}

let total = 0;
for (const url of process.argv.slice(2)) total += await check(url);
process.exitCode = total ? 1 : 0;
Enter fullscreen mode Exit fullscreen mode

Run against a page with an old JSON-LD block, the output looks like this:

200 https://example.com/blog/head-drift
  - og:description != description
    "Fresh description"
    "Old description"
  - og:title != JSON-LD headline
    "New & better title"
    "Old title"
Enter fullscreen mode Exit fullscreen mode

The regex parsing is deliberately simple. It's fine for checking your own pages, where you know the markup, and it's not meant for arbitrary HTML from the internet.

Not every difference is a bug. Some sites intentionally use a square crop of the cover in JSON-LD and a wide one in og:image, or a shorter meta description. The script reports, you decide. What I want to avoid is differences nobody chose.

It exits with code 1 when it finds anything, so you can run it after a deploy against a short list of important URLs and fail the job, or just run it by hand when something looks off.

Step 6: look at the card itself

Matching strings don't tell you how the result reads in a card. After a deploy I paste one or two of the URLs into the Twitter card checker on OG Scope, which shows which twitter:card it read and notes when a field fell back to Open Graph. If a fallback shows up that I didn't expect, it usually means toMetadata isn't being used on that route. The cards there are estimates drawn from the fetched HTML, not screenshots from X, but they're good enough to spot a wrong headline or a missing image before anyone else does.

Wrapping up

The pattern is small:

  1. One PageSeo object per page.
  2. toMetadata() and toJsonLd() derive every output from it, with canonical and og:url built from the same value.
  3. JSON-LD rendered with < escaped.
  4. A drift check against the live HTML for the cases types can't catch.

It takes an afternoon to set up on an existing project, and after that, changing a page's headline means changing it in one place.


I'm Abid Ali, a freelance full stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built OG Scope, a free link preview checker.

Top comments (0)