Skip to content

App Router: root layout html/body wrapped again by outer SSR, creating nested tags #880

@eduardornj

Description

@eduardornj

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

  1. Standard App Router root layout with <html> and <body> tags
  2. Add a <script type="application/ld+json"> inside <head> containing LocalBusiness with aggregateRating
  3. Build and deploy: npx vinext build && wrangler deploy
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions