[GH-ISSUE #880] App Router: root layout html/body wrapped again by outer SSR, creating nested tags #195

Open
opened 2026-05-06 12:38:02 +02:00 by BreizhHardware · 1 comment

Originally created by @eduardornj on GitHub (Apr 24, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/880

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.

Originally created by @eduardornj on GitHub (Apr 24, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/880 ### 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 <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 ```bash 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.
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 25, 2026):

I can't repro this, could you add a repro?

<!-- gh-comment-id:4318074814 --> @NathanDrake2406 commented on GitHub (Apr 25, 2026): I can't repro this, could you add a repro?
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#195
No description provided.