How to Fix: `` element behaves differently in React/HTML compared to Next.js 14/15
Why the <details> element behaves differently in React/HTML versus Next.js 14/15
If your <details> accordion opens and closes correctly in plain HTML or a basic React app but behaves inconsistently in Next.js 14/15, the problem is usually not the browser. It is typically a hydration mismatch caused by the way the server renders the initial markup and the client later attaches React state and event handling.
The native <details> element has built-in browser behavior. In a standard HTML page, the browser fully owns its open/closed state unless JavaScript explicitly changes it. In Next.js, especially with the App Router, Server Components, and client hydration, that same native behavior can conflict with React’s rendering model if the component is partially controlled, conditionally rendered, or initialized differently between server and client.
Understanding the Root Cause
The key issue is that <details> is a stateful native HTML element. Its open attribute controls whether the disclosure is expanded, but the browser can also toggle that state immediately when a user clicks the <summary> element.
In plain HTML, this is simple:
<details>
<summary>More info</summary>
<p>Hidden content</p>
</details>
The browser handles everything. But in Next.js 14/15, several things can interfere:
- Server-side rendering outputs the initial HTML before the browser becomes interactive.
- Hydration reuses that HTML and attaches React logic on the client.
- If the server rendered
openone way and the client expects another, React may reconcile the DOM unexpectedly. - If you mix native toggling with React-controlled state, the UI can appear to flicker, revert, or behave differently than in a standalone React app.
This often happens in patterns like:
<details open={someClientOnlyValue}>
<summary>Section</summary>
<div>Content</div>
</details>
If someClientOnlyValue depends on browser-only state, local storage, window size, URL state, or effects that only run in the client, then the server output and client expectation differ. That creates a hydration mismatch.
Another source of confusion is using onClick or onToggle while also letting the native element manage itself. In that case, React and the browser may both influence the same state at slightly different times.
In short, the bug appears because Next.js emphasizes SSR + hydration, while <details> is one of those HTML elements whose built-in browser state can drift from React state unless you choose a clear ownership model.
Step-by-Step Solution
The most reliable fix is to choose one of these two approaches:
- Use
<details>as a fully native uncontrolled element. - Use it as a fully React-controlled client component.
Do not mix both patterns casually.
Option 1: Keep it native and uncontrolled
If you do not need React state to control the accordion, let the browser handle it.
export default function FAQItem() {
return (
<details>
<summary>What is Next.js hydration?</summary>
<p>Hydration attaches React to server-rendered HTML.</p>
</details>
)
}
This is the safest option when you only need disclosure behavior and do not need to sync open state with app logic.
Option 2: Make it a client component and control the state explicitly
If you need deterministic behavior across Next.js 14/15, move the component to the client and make React the single source of truth.
'use client'
import { useState } from 'react'
export default function ControlledDetails() {
const [isOpen, setIsOpen] = useState(false)
return (
<details
open={isOpen}
onToggle={(e) => setIsOpen(e.currentTarget.open)}
>
<summary>Show advanced details</summary>
<p>This content is controlled by React state.</p>
</details>
)
}
This works because the component is explicitly marked with ‘use client’, so the state logic lives where the interaction happens.
However, if you want even stricter control, prevent the browser from being the primary state manager and toggle through the summary click yourself:
'use client'
import { useState } from 'react'
export default function ControlledDetails() {
const [isOpen, setIsOpen] = useState(false)
return (
<details open={isOpen}>
<summary
onClick={(e) => {
e.preventDefault()
setIsOpen((prev) => !prev)
}}
>
Show advanced details
</summary>
<p>This content is fully controlled by React.</p>
</details>
)
}
This pattern avoids the browser toggling the state independently before React updates it.
Avoid client-only initial state during SSR
If the initial open state depends on browser APIs, do not compute it during server render.
Problematic example:
'use client'
export default function BadExample() {
const isOpen = window.location.hash === '#open'
return (
<details open={isOpen}>
<summary>Section</summary>
<p>Content</p>
</details>
)
}
Safer version:
'use client'
import { useEffect, useState } from 'react'
export default function SafeExample() {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
setIsOpen(window.location.hash === '#open')
}, [])
return (
<details open={isOpen}>
<summary>Section</summary>
<p>Content</p>
</details>
)
}
This ensures the server and client start consistently, then update after mount.
If necessary, render only on the client
For highly interactive disclosure widgets that depend heavily on runtime state, a client-only import can remove SSR timing issues.
import dynamic from 'next/dynamic'
const ClientOnlyDetails = dynamic(() => import('./ControlledDetails'), {
ssr: false,
})
export default function Page() {
return <ClientOnlyDetails />
}
Use this carefully. It solves hydration-related discrepancies, but you lose some server-rendering benefits.
Best-practice checklist
- Use native uncontrolled
<details>when possible. - If state must be synchronized with React, use a client component.
- Avoid mixing browser-managed open state with competing React updates.
- Make sure the initial
openvalue is the same on server and client. - Use client-only rendering if the component fundamentally depends on browser-only conditions.
Common Edge Cases
1. The accordion opens, then immediately closes
This usually means the browser toggled the native state, then React re-rendered with a different open value. The fix is to make the component either fully native or fully controlled.
2. Hydration warnings appear in the console
If you see mismatch warnings, inspect anything that affects the initial open state: query params, hashes, local storage, media queries, user preferences, and async data.
3. It works in development but not in production
Production builds can expose timing differences more clearly. A component that seems fine in local testing may still have a subtle SSR/client state mismatch.
4. Nested interactive elements inside <summary>
Buttons, links, or custom click handlers inside <summary> can create odd toggle behavior. Keep summary content simple, or explicitly manage the toggle logic yourself.
5. Styling makes it look broken
Sometimes the open state is correct, but CSS hides the content or overrides the marker behavior. Verify with minimal styling first, then reintroduce custom styles gradually.
6. Multiple accordions need synchronized behavior
If only one panel should stay open at a time, native <details> alone is often not enough. Use React state at the parent level and control each item explicitly.
'use client'
import { useState } from 'react'
export default function AccordionGroup() {
const [openId, setOpenId] = useState('a')
return (
<>
{['a', 'b', 'c'].map((id) => (
<details key={id} open={openId === id}>
<summary
onClick={(e) => {
e.preventDefault()
setOpenId((prev) => (prev === id ? '' : id))
}}
>
Item {id.toUpperCase()}
</summary>
<p>Panel {id}</p>
</details>
))}
</>
)
}
FAQ
Does Next.js break the <details> element?
No. The element itself still works, but SSR and hydration can expose conflicts between native browser behavior and React-controlled state. The issue is architectural, not that Next.js removes support for the tag.
Should I always add 'use client' for <details>?
No. If you only need the default browser disclosure behavior, keep it simple and render native <details>. Add ‘use client’ only when React must control or observe the open state.
Is onToggle enough to fix the problem?
Sometimes, but not always. onToggle helps React observe state changes, but if your initial server markup and client state do not match, or if React is fighting the native toggle behavior, you may still get inconsistent results.
The most robust resolution for this GitHub issue is to treat <details> as either a pure native element or a fully controlled client component. Once you remove split ownership of the open state, the React/Next.js behavior becomes predictable again.
For framework-specific rendering details, review the Next.js documentation and compare the reproduction linked in the issue with a minimal client-controlled version to confirm where the hydration mismatch begins.