Description
When the App Router root layout at app/[locale]/layout.tsx returns <html>...<body>...</body></html> (as Next.js docs require), the rendered HTML in production ends up with everything nested inside another <html><body> wrapper:
<html lang="en">
<head>...</head>
<body>
<html lang="en">
<head>...</head>
<body>
...actual content...
</body>
</html>
</body>
</html>
Two <html>, three <head>, two <body> in the final output. Tested on 0.0.43, 0.0.42, 0.0.41, 0.0.40.
Why this matters
Google Rich Results Test parses the inner <html> as a separate document, so any <script type="application/ld+json"> placed in the inner <head> gets counted twice. My site has a single LocalBusiness schema with aggregateRating in the root layout. Validator reports "Review has multiple aggregate ratings" because it sees two LocalBusiness objects sharing the same @id, and marks the rich result as invalid.
Steps to Reproduce
- Standard App Router root layout with
<html> and <body> tags
- Add a
<script type="application/ld+json"> inside <head> containing LocalBusiness with aggregateRating
- Build and deploy:
npx vinext build && wrangler deploy
- Paste the deployed URL into https://search.google.com/test/rich-results
Validator flags "multiple aggregate ratings" and the schema is rejected.
Quick check on any Vinext site in prod
curl -s https://yoursite.com | grep -c '<html'
# Expected: 1
# Vinext returns: 2
Workaround I landed on
Removed <html>, <head>, <body> from the root layout, returned a <> fragment, let React 19 hoist <link>/<meta>/<script> to the document head. Wrapped the rest in <div className="min-h-screen flex flex-col"> to replace body-level flex.
Works (Rich Results now validates with 0 errors), but breaks the Next.js App Router contract which explicitly says the root layout must include <html> and <body>. Will need to revert once Vinext stops double-wrapping.
Happy to put together a minimal repro if it helps narrow this down.
Description
When the App Router root layout at
app/[locale]/layout.tsxreturns<html>...<body>...</body></html>(as Next.js docs require), the rendered HTML in production ends up with everything nested inside another<html><body>wrapper:Two
<html>, three<head>, two<body>in the final output. Tested on 0.0.43, 0.0.42, 0.0.41, 0.0.40.Why this matters
Google Rich Results Test parses the inner
<html>as a separate document, so any<script type="application/ld+json">placed in the inner<head>gets counted twice. My site has a singleLocalBusinessschema withaggregateRatingin the root layout. Validator reports "Review has multiple aggregate ratings" because it sees twoLocalBusinessobjects sharing the same@id, and marks the rich result as invalid.Steps to Reproduce
<html>and<body>tags<script type="application/ld+json">inside<head>containingLocalBusinesswithaggregateRatingnpx vinext build && wrangler deployValidator flags "multiple aggregate ratings" and the schema is rejected.
Quick check on any Vinext site in prod
Workaround I landed on
Removed
<html>,<head>,<body>from the root layout, returned a<>fragment, let React 19 hoist<link>/<meta>/<script>to the document head. Wrapped the rest in<div className="min-h-screen flex flex-col">to replace body-level flex.Works (Rich Results now validates with 0 errors), but breaks the Next.js App Router contract which explicitly says the root layout must include
<html>and<body>. Will need to revert once Vinext stops double-wrapping.Happy to put together a minimal repro if it helps narrow this down.