Skip to content
Neaptidestudio
blog

Next.js Metadata in the Body: Nine Tested Conditions

Neaptide · October 12, 2026 · 8 min read

We tested Next.js 15.5.22 across nine metadata conditions. Compare HTTP HTML and browser DOM, and see why a body canonical still needs separate scrutiny.

On this page
Next.js metadata comparison: head and body locations across nine conditions and eighteen HTTP responses.

Finding metadata below the closing head tag can be a consequence of Next.js streaming. In our local production fixture, asynchronous metadata appeared in body for two request profiles and in head for a third. The values were present in every completed response, but that does not establish that a search engine accepts every tag in that location.

Start by identifying the tag, the request profile and the representation you are inspecting. Our test explains delivery behavior. For canonical links, also apply Google's head requirement; seeing the correct URL somewhere in the document is not enough to declare the implementation correct.

What we tested

On October 12, 2026, we tested Next.js 15.5.22 with React and React DOM 19.2.6 and Node v25.2.1. Three modes crossed with three explicit HTTP User-Agent profiles produced nine conditions. We ran every condition twice, retaining 18 complete HTTP 200 responses, headers and HTML. Both rounds agreed on tag counts, values and placement.

The modes were a static metadata object, an asynchronous generateMetadata function under the default configuration, and that same function with htmlLimitedBots: /.*/. Both routes used force-dynamic. Here, static describes the metadata export, not a prerendered page. The async function deliberately waited 250 ms, with no external fetch. That delay makes the delivery sequence observable; it is not a realistic workload or a performance score.

The HTTP response changed with the request profile

Nine-condition matrix: static and blocking metadata appear in head for all profiles; default async metadata appears in body for browser-like and Googlebot profiles, and in head for Twitterbot.
Measured October 12, 2026. Each cell covers title, description and canonical in two complete HTTP responses. All three tags had the same location within each condition.
Measured October 12, 2026. Each cell covers title, description and canonical in two complete HTTP responses. All three tags had the same location within each condition.
Metadata modeBrowser-like UAGooglebot UATwitterbot UA
Static object · defaultheadheadhead
Async function · defaultbodybodyhead
Async function · blockingheadheadhead

The collector used a Chrome 134-like string, Googlebot/2.1 and Twitterbot/1.0. These are HTTP header values, not visits by those services. All requests used Accept: text/html and Accept-Encoding: identity, without cookies or client-navigation headers. The download contains the exact strings and requests.

The browser kept the default async tags in body

We separately loaded all three modes in the connected browser, whose received User-Agent reported Chrome 154. In the rendered DOM, title, description and canonical were in head for the static and blocking modes. They remained in body for the default async mode. We did not observe a move into head. These three browser loads are separate observations from the 18 HTTP requests; they are not Googlebot rendering tests.

The browser kept the default async tags in body
ModeRendered DOM: all three tags
Static object · defaulthead
Async function · defaultbody
Async function · blockinghead

Canonical needs its own decision

Next.js documents body delivery for streamed metadata and blocking delivery for HTML-limited bots. That explains the placement pattern we reproduced. Google's canonical documentation, however, says the HTML canonical link is accepted in head; its JavaScript example also inserts the link into document.head. The framework's general guidance does not settle this tag-specific requirement.

Our default async fixture leaves canonical in body in both the collected response and the browser DOM. We cannot conclude that Google accepted or rejected it: the fixture was local, used noindex and was never submitted for indexing. Treat this as a point requiring verification on the deployed route. If canonical remains outside head, use an implementation that satisfies Google's documented placement and recheck the result. The blocking configuration below achieved head placement in our fixture; it is one tested option, not a measured ranking improvement.

A focused check for your own route

  1. Save the full production response for the affected URL, with the request headers and deployment version. Inspect real title, description and canonical elements; text inside a React script is not a second HTML tag.
  2. Check the rendered DOM separately. Record values, duplicates and head/body ancestry. A correct browser-tab title alone says nothing about the canonical link.
  3. Compare the profiles relevant to the consumer that is failing. Keep browser, crawler-string and actual external-service observations separate; a spoofed User-Agent only tests your server's response selection.
  4. Apply the requirement for the specific tag. For canonical, inspect the deployed page through available Google tools and its canonical reporting, while ensuring the HTML link is in head. A local DOM capture cannot tell you Google's selected canonical.
  5. Change the smallest relevant implementation choice, rebuild, and repeat the same checks. If changing metadata streaming, evaluate the loading behavior on the real workload as well; our artificial delay provides no production performance estimate.

The configuration we actually tested

For the third mode, we rebuilt the same app with this option. It placed all three tags in head for all tested profiles. The option replaces the default bot-matching rule; /.*/ matches every User-Agent. Preserve the rest of your configuration and assess the scope before adopting it.

// next.config.mjs
export default {
  htmlLimitedBots: /.*/,
};

Changing this option affects metadata delivery across the app. The experiment did not test a custom bot-only regex, production caches or navigation between routes, and it does not justify switching every site to blocking metadata.

Repeat the experiment

Download the fixture and lockfile. Its README gives the build and start commands for both configurations, followed by the Python collector. Start the two servers in separate terminals, then run the collector from the extraction root. The fixture includes an artificial delay, noindex and reserved example.test canonical URLs: keep it local. The evidence archive contains original response HTML and DOM snapshots; results.json contains the complete profiles and parsed observations.

Inspect the evidence

This is one version, two local builds and three browser page loads. We did not test a CDN, remote crawlers, client-side navigation, metadata failures or cache components. We measured neither indexing nor rankings, AI citations, traffic, conversion or Core Web Vitals. The same researcher performed collection and checking. Recheck behavior on your own version and deployment.

faq

The short version

Why do metadata values also appear inside React script payloads?

A response can contain serialized data as well as actual HTML elements. Our parser counts title, meta and link elements and ignores matching strings in scripts. Check element counts and values rather than counting text matches.

Does an async generateMetadata function always behave like this fixture?

This fixture explicitly forced request-time rendering and added a delay. It does not cover prerendered routes, cached data or other Next.js versions. The async keyword alone does not reproduce all of our conditions.