Build Notes6 min read

How I Found My Next.js JSON-LD Was Invisible to AI Crawlers

By Ergini, Software & AI Developer

TL;DR

This site rendered its JSON-LD through next/script with strategy beforeInteractive. On Next.js 15.5 with the App Router, that serialized every block into a self.__next_s.push() call, so the server HTML held zero application/ld+json elements: 0 of 7 on a typical service page, for four months. Google renders JavaScript and saw the schema. GPTBot, ClaudeBot and PerplexityBot, which did not execute JavaScript in Vercel and MERJ's crawler study, saw none of it. The fix is a plain script tag, as the Next.js docs recommend, plus a build check so it cannot come back.

Zero of seven

Every page on this site describes itself in JSON-LD: a breadcrumb trail, the service or article it is, the questions it answers, and the site-wide Person, ProfessionalService and WebSite schema. A typical service page carries seven blocks.

On 11 September 2026 I was building a new section of the site and looked at the raw HTML of one of its pages, the bytes a server sends before any JavaScript runs. I expected seven JSON-LD blocks. There were none. So I fetched two long-standing production pages the same way, with curl and a GPTBot user agent:

PageJSON-LD blocks on the pageReal ld+json tags, beforeAfter the fix
/services/ai-integration707
/blog/rag-architecture-tutorial606

The schema was not missing. It was in the HTML, just not as markup. Each block sat inside an inline script as the argument to self.__next_s.push(): fourteen references on the service page, two per block. That array is a queue the Next.js client runtime works through when it starts, creating the real script elements then. In a browser, a moment later, everything looks correct. In the file the server sends, there is no structured data at all.

Git says the pattern arrived with the site's very first schema, on 2 May 2026. For just over four months, the site shipped all of its structured data as JavaScript.

Why nothing flagged it

Google renders JavaScript. Googlebot queues pages for rendering in a headless Chromium, runs their scripts and indexes what the rendered page contains, so Google most likely saw every block. Google's testing tools render too, and the DevTools Elements panel shows the rendered DOM, where the tags exist. Every check I would normally reach for was looking at the page after JavaScript had run.

The crawlers that do not run JavaScript are the ones a site like this one increasingly cares about.

Which crawlers only read the raw HTML

In December 2024 Vercel and MERJ published an analysis of AI crawler traffic across Vercel's network. None of the major AI crawlers they measured rendered JavaScript: not OpenAI's OAI-SearchBot, ChatGPT-User or GPTBot, not Anthropic's ClaudeBot, not PerplexityBot, Meta-ExternalAgent or Bytespider. ChatGPT's and Claude's crawlers did fetch JavaScript files, 11.5 and 23.8 percent of their requests, and did not execute them. The exceptions were Gemini, which runs on Googlebot's infrastructure, and AppleBot, which renders with a browser.

That study is from 2024, and crawlers change. But for anything you want an assistant to read, the conservative assumption is the right one: if it is not in the server HTML, some of them will never see it.

What next/script does with an inline JSON-LD block

The code was an easy pattern to reach for, because next/script feels like the Next.js way to put a script tag on a page. Every layout and page with schema looked like this:

import Script from "next/script";

<Script
  id="ld-faq"
  type="application/ld+json"
  strategy="beforeInteractive"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>

beforeInteractive exists for code that has to run before the page becomes interactive, such as consent managers and bot detection. In my Next.js 15.5 App Router build, an inline script with that strategy was not written into the HTML as an element. It was serialized into the __next_s queue and turned into an element on the client. That is useful for code that must execute early. It is wrong for data that has to be read by something that never executes anything.

The Next.js JSON-LD guide says as much. Its recommendation is to render structured data as a plain script tag in your layout or page, and it notes that next/script is optimized for loading and executing JavaScript, while JSON-LD is data, so the native tag is the right choice.

The fix

<script
  id="ld-faq"
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>

Drop the import, lowercase the tag, delete the strategy. That was the whole change, in eleven files. If any field in your schema can contain text from users or a CMS, also escape the less-than character before it reaches the page, as the Next.js guide suggests: JSON.stringify(data).replace(/</g, "\u003c"). Mine is built entirely from constants in the codebase, so I left it plain.

How to check from a terminal

# JSON-LD blocks a non-rendering crawler can read
curl -s https://example.com/page | grep -o '<script[^>]*application/ld+json' | wc -l

# Blocks queued for client-side injection instead
curl -s https://example.com/page | grep -o '__next_s' | wc -l

Count matches with grep -o rather than grep -c: a Next.js page is often one long line, so a line count reads 1 whether there are seven blocks or none. If the first number is zero and the second is not, your schema is JavaScript.

Keeping it fixed

A fix like this regresses the first time someone pastes an old snippet back in. This site has an audit script for the things I never want to break again, and it now fails if any file renders JSON-LD through next/script:

for (const file of sourceFiles(["app", "components"])) {
  const src = readFileSync(file, "utf8");
  for (const block of src.match(/<Script\b[\s\S]*?\/>/g) ?? []) {
    if (block.includes("application/ld+json")) {
      fail(`JSON-LD rendered through next/script: ${file}`);
    }
  }
}

Crude, and enough. The rule it enforces is short: structured data is markup, so it goes in the markup.

What changed, and what I cannot tell you yet

After the deploy, the same service page serves seven real JSON-LD elements to a GPTBot user agent, and the blog post six. Google should be unaffected either way. Whether assistants now quote these pages more often, I do not know yet, and how much any AI system leans on schema at all is not something I can measure from outside. What I can say is that the answers and prices in that schema are the most compact statement of fact on each page, and a crawler that reads only HTML now gets them.

The broader lesson is one I keep relearning: check the artifact, not a tool's opinion of it. The browser, DevTools and the validators all looked at the rendered page. What mattered was the file the server sends. The same habit caught the wrong favicon Google showed for this site and the AI Mode queries hiding in my Search Console.

Frequently asked questions

Does next/script with strategy beforeInteractive put JSON-LD in the server HTML?

It did not on this site. On Next.js 15.5 with the App Router, every inline JSON-LD block rendered through next/script with strategy beforeInteractive was serialized into a self.__next_s.push() call inside an inline script, and production pages contained zero application/ld+json elements. A plain script tag renders straight into the server HTML, which is what the Next.js JSON-LD guide recommends.

Do AI crawlers execute JavaScript?

Mostly not, according to Vercel and MERJ's December 2024 analysis of crawler traffic. OpenAI's GPTBot, OAI-SearchBot and ChatGPT-User, Anthropic's ClaudeBot, PerplexityBot, Meta-ExternalAgent and Bytespider did not render JavaScript. Gemini, which uses Googlebot's infrastructure, and AppleBot did. Crawlers change, so the safe assumption is that the server HTML is the only thing every crawler reads.

How can I check whether my JSON-LD is visible without JavaScript?

Fetch the page with curl and count the script elements with type application/ld+json in the raw response, or use view-source rather than the DevTools Elements panel, which shows the rendered DOM. If the count is zero but your schema text appears inside a __next_s push, the blocks are being injected on the client and crawlers that do not run JavaScript cannot see them.

Was Google affected by client-injected JSON-LD?

Probably not. Googlebot renders pages in a headless Chromium before indexing them, so it runs the scripts that inject the blocks. That is also why the problem survives unnoticed: Google, its testing tools and the browser all look at the rendered page. The gap only matters for crawlers that read the raw HTML.

Should JSON-LD be escaped in Next.js?

The Next.js JSON-LD guide points out that JSON.stringify does not sanitize strings used in XSS injection and suggests replacing the less-than character with its unicode escape, \u003c, before rendering. That matters whenever a field can contain text from users or a CMS. Schema built entirely from your own constants carries no such risk.