Head vs Body: JSON-LD in Next.js 16, and the ProfilePage bug I didn’t see coming
5 min read

Next.js 16’s JSON-LD guide favors rendering in the body, but some crawlers still scan head-first. Here’s my layout-head split and a ProfilePage mainEntity fix.
AI crawlers are getting weird.
Some of them claim they “understand pages” and then still behave like it’s 2009:
- they skim the HTML
- they sniff the
<head> - they sometimes miss things that are perfectly valid… but live deep in the
<body>
That creates a small challenge in Next.js.
Because the official App Router guidance for JSON-LD leans toward rendering a <script type="application/ld+json">...</script> inside the page output (i.e., in the body tree).
It’s not wrong.
It’s just not always aligned with how some crawlers actually behave.
So I took a slightly opinionated route:
If the data is stable and I really want it discovered, I embed it in the layout’s
<head>.
That includes things like:
- Organization
- Website
- Author/Person
…and then I keep route-specific structured data close to the route.
This post is about that split — and the tiny ProfilePage mistake that Google Search Console politely yelled at me for.
The context: my sites are basically two content types
The majority of my content fits into two buckets:
- WebPage (home, listings, legal, etc.)
- Article (posts)
…and there’s a third one that matters more than I expected:
- ProfilePage
At the moment, I have a single author profile (me), but my backend already supports multiple authors.
So I want the “author identity” story to be correct now — before it becomes a migration problem.
Next.js 16 JSON-LD: what the docs say, and what I actually do
Next.js gives you a clear recommendation:
- create a JSON object
- output a
<script type="application/ld+json"> - keep it server-rendered
Now here’s my deviation:
- the JSON-LD I consider critical and site-wide goes into
<head>vialayout.tsx
In my RootLayout, I embed Organization, WebSite, and Person JSON-LD directly inside <head>.
// app/layout.tsx (excerpt)<head><link rel='icon' href='/favicon.png' type='image/png' /><OrganizationJsonLd>{organizationJsonLd}</OrganizationJsonLd><WebSiteJsonLd>{websiteJsonLd}</WebSiteJsonLd><PersonJsonLd>{authorJsonLd}</PersonJsonLd></head>
This is intentionally a “SEO-first” compromise:
- stable identity signals are immediately visible in
<head> - dynamic page signals are generated where the data actually exists
My JSON-LD baseline: one tiny component, many schema wrappers
I keep JSON-LD rendering as a tiny component wrapper:
- it prints a
<script type="application/ld+json"> - it takes either an explicit object or “generate from data”
- and it has schema-specific wrappers like
PersonJsonLd,ProfilePageJsonLd, etc.
// components/json-ld.component.tsx (excerpt)export function JsonLd({ children, data, options, className }: JsonLdProps) {const jsonLd = children ?? jsonLdGenerator(data ?? undefined, options);if (!jsonLd) return null;return (<scripttype='application/ld+json'className={className}dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}/>);}// Pre-defined components for common schema typesexport const PersonJsonLd = createJsonLdComponent('Person');export const ProfilePageJsonLd = createJsonLdComponent('ProfilePage');
This keeps the markup consistent, and it keeps the “what schema are we emitting?” decision explicit.
The trigger: Google Search Console pointed at my profile page
I recently shipped a small SEO checkup tool.
Between that tool and Google Search Console warnings, I noticed something embarrassing:
- my profile page was eligible for ProfilePage structured data
- but the structured data I sent didn’t include the
mainEntityrelationship
In plain English:
- I had “a profile page”
- but I wasn’t clearly telling Google who the page is about
The missing link: mainEntity (and why I care)
My profile page is /goker.
It’s also SEO-important:
- it’s linked from navigation
- it’s meant to be discoverable
- it’s the canonical “about this author” page
So I want it to be boring and correct.
And the correction is conceptually simple:
- the page is a ProfilePage
- the “main entity” is a Person
- and that Person should be the same identity I already publish in the global
<head>
Which brings us to @id.
My current baseline: the Person identity already exists in <head>
This is the JSON-LD I’m already shipping on every page:
// config/json-ld.author.ts (excerpt)export const authorJsonLd = {'@context': 'https://schema.org','@type': 'Person','@id': `${AUTHOR.url}#person`,name: AUTHOR.name,url: AUTHOR.url,image: AUTHOR.image,sameAs: AUTHOR.sameAs,// ...};
The important part is:
- the Person has a stable
@id→${AUTHOR.url}#person
That’s the identity anchor.
Now I just need the profile route to point at it.
The practical fix: make /goker a ProfilePage, and point mainEntity to the Person @id
On the profile route, I already render a ProfilePage JSON-LD component:
// app/(pages)/goker/profile.component.tsx (excerpt)export default async function Profile({ post }: { post: PostDto }) {// ...return (<><article itemScope itemType='https://schema.org/ProfilePage'>{/* profile content */}<ProfilePageJsonLd data={post} /></article></>);}
So the job becomes:
- ensure
ProfilePageemitsmainEntity - ensure
mainEntitypoints to the same Person@idthat I ship in<head>
Here’s the relevant part of my generator:
// lib/json-ld.generator.ts (excerpt)} else if (schemaType === 'ProfilePage') {Object.assign(baseSchema, {name: pageTitle || SITE_NAME,description: description,mainEntity: {'@type': 'Person','@id': `${canonicalUrl}#person`,name: pageTitle,},});} else if (schemaType === 'Person') {
The key idea:
ProfilePage.mainEntity.@idshould match the Person@id
If your Person @id is ${AUTHOR.url}#person, then your ProfilePage should reference that exact value.
No guessing.
No parsing posts to “generate a profile”.
Just one identity, used consistently.
Why not generate a profile from post content?
Because it’s the wrong abstraction for my needs.
Yes, you could parse every post, detect author mentions, and derive a profile.
But:
- it’s fragile
- it’s not needed
- it’s the kind of “smart” system that breaks quietly
If I ever need richer author structured data, I’ll do it properly:
- extend the CMS schema
- or add a dedicated author profile section
For now, the identity is stable and explicit.
Testing: Rich Results is the judge
After adjusting the ProfilePage JSON-LD, I run it through:
If your JSON-LD is valid but not eligible for rich results, that’s fine.
What matters is:
- the schema is valid
- the entity relationships are coherent
- Google sees what you intended
Quick recap
- Next.js 16 supports JSON-LD cleanly in App Router (render a JSON-LD script)
- Some crawlers behave like head-first scanners, so I put stable structured data into the layout
<head> - My content model is mostly WebPage + Article, plus a profile page that deserves ProfilePage markup
- The profile fix is simple:
ProfilePage+mainEntitypointing to the existingPersonvia@id