Most Next.js sites ship with good metadata and no structured data. Title tags, Open Graph, canonical URLs — all correct. And then nothing that tells a search engine what kind of thing the page is or who made it.
That gap matters more than it used to. Structured data is how a search engine moves from "this page contains the string Hugo Naili" to "this page is authored by an entity named Hugo Naili who also has these profiles and works here." The second is a fact about the world. The first is a substring match.
This is a practical walkthrough: what to add, where it goes in the App Router, and the mistakes that make it silently useless.
What JSON-LD actually is
A block of JSON in a <script type="application/ld+json"> tag describing the page in Schema.org vocabulary. It is not rendered, does not affect layout, and adds a negligible amount to your bundle.
Three properties carry most of the weight:
-
@type— what kind of thing this is.Person,BlogPosting,Organization,WebSite. -
@id— a stable, unique URI for the entity. Underused and important; more below. -
sameAs— an array of URLs where the same entity appears elsewhere.
sameAs is the one people skip and shouldn't. It is how you assert that the GitHub account, the dev.to profile, and the personal site are the same person rather than three coincidences. Entity consolidation is the whole game for personal sites.
Person schema, in the root layout
This belongs on every page, so it goes in app/layout.tsx:
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const personSchema = {
'@context': 'https://schema.org',
'@type': 'Person',
'@id': 'https://hugonaili.com/#person',
name: 'Hugo Naili',
url: 'https://hugonaili.com',
jobTitle: 'Senior Demo Engineer',
worksFor: {
'@type': 'Organization',
name: 'Contentful',
url: 'https://www.contentful.com',
},
description:
'Senior software engineer with over ten years of experience building React and Next.js systems, design systems, and technical demo environments.',
knowsAbout: [
'React',
'Next.js',
'TypeScript',
'Frontend architecture',
'Design systems',
'GraphQL',
],
sameAs: [
'https://github.com/hugonaili',
'https://dev.to/hugonaili',
'https://gitlab.com/hugonaili',
'https://medium.com/@HugoNaili',
],
}
return (
<html lang="en">
<body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
/>
{children}
</body>
</html>
)
}
Two notes on this.
The @id is doing real work. https://hugonaili.com/#person is a stable identifier for you as an entity. Every article you publish can reference that exact string as its author, and a parser resolves all of them to one entity rather than treating each article's author as a separate unnamed person. Without it, you have N disconnected author objects that happen to share a name.
knowsAbout is not decorative. It is a topical association between an entity and subject areas, and it costs nothing.
BlogPosting schema, per article
Make it a component so every post gets consistent output:
// components/BlogPostSchema.tsx
interface BlogPostSchemaProps {
title: string
description: string
slug: string
publishedAt: string // ISO 8601: '2026-08-03'
updatedAt?: string
imageUrl: string
tags?: string[]
}
export default function BlogPostSchema({
title,
description,
slug,
publishedAt,
updatedAt,
imageUrl,
tags = [],
}: BlogPostSchemaProps) {
const url = `https://hugonaili.com/blog/${slug}`
const schema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': url,
headline: title,
description,
url,
image: imageUrl,
datePublished: publishedAt,
dateModified: updatedAt ?? publishedAt,
keywords: tags.join(', '),
author: { '@id': 'https://hugonaili.com/#person' },
publisher: { '@id': 'https://hugonaili.com/#person' },
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
inLanguage: 'en-US',
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
}
Note author and publisher are references — { '@id': ... } — not inline objects. This is the payoff from defining @id in the layout. The parser follows the reference to the full Person definition. One canonical description, referenced everywhere.
Usage:
// app/blog/[slug]/page.tsx
import BlogPostSchema from '@/components/BlogPostSchema'
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return (
<>
<BlogPostSchema
title={post.title}
description={post.excerpt}
slug={slug}
publishedAt={post.publishedAt}
updatedAt={post.updatedAt}
imageUrl={`https://hugonaili.com/images/blog/${post.image}`}
tags={post.tags}
/>
<article>{/* ... */}</article>
</>
)
}
Breadcrumbs, which are cheap and visible
BreadcrumbList changes what your URL looks like in results — hugonaili.com › blog › article-title instead of a raw path. Small change, measurable click-through effect.
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://hugonaili.com' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://hugonaili.com/blog' },
{ '@type': 'ListItem', position: 3, name: title, item: url },
],
}
Emit it alongside the BlogPosting. Multiple JSON-LD blocks on one page are fine and normal.
Mistakes that make this silently useless
Canonical says one thing, page content says another. If your canonical points to your own domain but the article footer links to another copy labeled "read the original here," you have contradicted yourself. Schema is a claim about authority; anything on the page that undercuts the claim undercuts the schema. This applies doubly to cross-posted content.
Assets hosted on the platform you cross-posted to. Your schema asserts you published the article. Every image loads from another platform's CDN. That's a signal in the opposite direction, and it's free to fix — download the images, serve them yourself.
Dates that aren't ISO 8601. 'August 3, 2026' is not a date to a parser. '2026-08-03' is. This fails silently; nothing warns you.
dateModified earlier than datePublished. Usually a bug where one field is hardcoded. It invalidates the whole block.
Describing content that isn't on the page. The schema must describe what a user actually sees. Headline mismatches, or an author who appears nowhere in the visible page, read as manipulation.
Missing a visible byline. If the schema names an author but the rendered page shows no author name, photo, or bio, you have a mismatch between what you're claiming and what the page demonstrates. Add the visible byline. It's a better page anyway.
Verifying it
Google's Rich Results Test at search.google.com/test/rich-results parses a live URL and shows you exactly what it extracted. Schema.org's validator is stricter about vocabulary correctness and worth running once per schema type.
Both catch syntax errors. Neither catches the semantic problems above — a page can validate perfectly while contradicting itself in the body copy. Read the rendered page and ask whether a person would agree with what the JSON claims.
What to expect
Structured data is not a ranking factor in the direct sense. It doesn't move you up the page.
What it does is make your pages legible — parseable as entities with relationships rather than documents with keywords. That legibility is what enables enhanced snippets, breadcrumb display, and eventually entity recognition. It also increasingly determines whether AI-driven search surfaces describe you accurately, since those systems lean heavily on structured signals.
The work is a couple of hours. The main cost of skipping it is that everything downstream that depends on a machine understanding who you are has to infer it from prose instead.
Top comments (0)