Skip to content
Engineering

Enabling Semantic Linked Data in Your Next.js Blog

A Guide to JSON-LD Metadata

Metadata (the author, publication date, categories, and tags behind a post) is what lets search engines rank your content sensibly, generate rich snippets in results pages, and lets digital assistants and social platforms categorize what you write. While microdata embedded in HTML markup is one way to provide it, the most effective machine-readable format is , a standard for linked data that enables a .

JSON-LD is not woven into the page's visible HTML. It ships as a raw data block alongside it, which means crawlers can extract and interpret it without you touching the layout. In this post, I'll cover just enough of the concepts to be useful and then walk through how this blog implements it in Next.js.

Understanding JSON-LD

is a method of encoding linked data using JSON. JSON-LD 1.0 became a W3C Recommendation in January 2014, with JSON-LD 1.1 following in 2020. It uses the familiar JSON structure to express information using linked data vocabularies, most commonly , a collaborative project that provides shared vocabularies for marking up structured data on the internet.

In practice, a JSON-LD block is just a <script> tag of type application/ld+json in the page's markup. A minimal description of a blog post looks like this:

{  "@context": "https://schema.org",  "@type": "BlogPosting",  "headline": "Enabling Semantic Linked Data in Your Next.js Blog",  "datePublished": "2023-09-05",  "author": {    "@type": "Person",    "name": "Ahmad Assaf"  }}

Search engines parse these blocks independently of your visible HTML, so you can describe your content as richly as you like without touching the layout. The rest of this post walks through how this very blog wires that up, and every file referenced lives in the if you want to lift the structure wholesale.

How This Blog Implements JSON-LD

The implementation is deliberately layered so that every fact lives in exactly one place:

  • is the single source of identity truth: site title, description, canonical URL, and the author's social profiles. Every metadata surface (page metadata, JSON-LD, RSS, the manifest, and the sitemap) derives from this one object.
  • holds the Schema.org entities as plain constants: the Person, the WebSite, the Blog, and the organisations the author is affiliated with.
  • exports linkedDataGenerator, which turns a post's frontmatter into a full BlogPosting description at build time.

Describing the Author and the Site

The static entities are just exported objects. The author is a Schema.org Person that pulls its identity values from the shared metadata module instead of hard-coding them:

export const author = {  '@context': 'https://schema.org',  '@type': 'Person',  'affiliation': organisation,  'alumniOf': alumni,  'email': metadata.email,  'jobTitle': 'CTO at Mav9',  'name': metadata.author,  'sameAs': [ metadata.twitter, metadata.github, metadata.linkedin ],  'url': metadata.siteUrl};

The WebSite entity reuses the same values, and the Blog entity simply derives from it by spreading and overriding the fields that differ:

export const blog = {  ...website,  '@type': 'Blog',  'mainEntityOfPage': {    '@id': metadata.siteUrl,    '@type': 'WebSite'  },  'url': `${metadata.siteUrl}/blog`};

Because these are plain constants that share one metadata source, a change like a new social profile or a domain move propagates to every JSON-LD block automatically.

Injecting the Site-Wide Entity

The root layout hands the WebSite entity to the layout container, which serializes it into the document head for every page:

import { website } from '@/data/meta/JSON-LD/website';
<LayoutContainer jsonLd={ website } metadata={ siteMetadata } navigation={ NavigationMetadata }> {children}</LayoutContainer>

Under the hood that renders the familiar script tag:

<script type='application/ld+json' dangerouslySetInnerHTML={{ '__html': jsonLdMarkup }} />

Generating a BlogPosting per Article

Each article page then adds its own BlogPosting block. The generator maps frontmatter onto Schema.org fields, including the tags as linked Thing entities, the reading-time word count, and the Open Graph image the post already generates for social cards:

export function linkedDataGenerator(post) {  const postUrl = `${siteMetadata.siteUrl}${postPath(post)}`;  const ogImage = `${siteMetadata.siteUrl}/api/og?slug=${post.slug}`;
return { '@context': 'https://schema.org', '@type': 'BlogPosting', 'about': (post.tags || []).map((tag) => ({ '@id': `${siteMetadata.siteUrl}/blog/tags/${slugify(tag)}`, '@type': 'Thing', 'name': tag })), author, 'dateModified': post.updated || post.date, 'datePublished': post.date, 'headline': post.title, 'image': { '@type': 'ImageObject', 'url': ogImage, 'width': '1200', 'height': '630' }, 'isPartOf': blog, 'keywords': post.tags || [], 'mainEntityOfPage': { '@id': postUrl, '@type': 'WebPage' }, 'url': postUrl, 'wordCount': post.readingTime?.words || 0 };}

The renders it next to the article content:

<script type='application/ld+json' dangerouslySetInnerHTML={{ '__html': JSON.stringify(linkedDataGenerator(post)) }} key='post-jsonld'/>

Since this runs in a server component at build time, the structured data ships as static HTML. There is no client-side JavaScript cost and crawlers see it on the first request.

Validating the Output

Before trusting any of this, verify what actually ships:

  • View source: open any article, view the page source, and search for ld+json. You should find two blocks, the site-wide WebSite and the per-article BlogPosting.
  • : paste an article URL and confirm the BlogPosting is detected and eligible for rich results.
  • : a stricter, vendor-neutral check that surfaces typos in property names and malformed nesting that Google's tool quietly ignores.

Run these once after wiring things up and again whenever the generator changes; a single misspelled property fails silently otherwise.

Wrapping Up

Structured data is one of the cheapest SEO and interoperability wins available to a Next.js blog: a handful of constant objects, one generator function, and two script tags. The layering matters more than the volume, so keep identity facts in one module, derive the Schema.org entities from it, and generate per-article descriptions from the frontmatter you already maintain.

The complete, working setup, from the metadata module to the generator and the injection points, is in the . Lift it, adapt the vocabulary to your content types, and let the machines finally understand what you write.

The opinions and views expressed on this blog are solely my own and do not reflect the opinions, views, or positions of my employer or any affiliated organizations. All content provided on this blog is for informational purposes only.