A web component can run inside a Next.js page, but its browser registration, React rendering, and TypeScript declaration are separate concerns. If an imported module accesses HTMLElement or customElements while the server evaluates it, adding 'use client' to the consuming file may not be enough.
This guide uses a small custom element to show an App Router integration for React 19. The page keeps its surrounding content on the server, loads the element after mounting, and shows a visible fallback if registration fails. The example is instructional, not a production incident or a benchmark.
Decide what needs to render on the server
Rendering <demo-counter> in HTML and executing its JavaScript class are different operations. The tag can exist before its definition is registered. That does not mean its interactive contents are ready.
Next.js prerenders Client Components on the initial request. Browser-dependent modules should therefore be evaluated in an appropriate browser-only path. In this example, a dynamic import inside an effect provides that boundary.
This is a useful approach for an optional interactive widget. Keep the heading, instructions, and meaningful page content outside the widget so visitors still receive them if JavaScript fails. Do not hide the only useful content of a public page behind a loading placeholder.
Client-only loading is not the only possible architecture. Declarative shadow DOM allows shadow markup to be represented in HTML; a component library may also provide an SSR integration. That requires its own rendering and hydration support. React's ability to output a custom tag does not automatically serialize a component's imperative shadow tree.
1. Define a small browser-only element
Create app/components/demo-counter.ts. It deliberately uses browser APIs at module scope, so do not statically import it into the route or wrapper.
```ts class DemoCounter extends HTMLElement { private count = 0 private button: HTMLButtonElement
constructor() { super() const root = this.attachShadow({ mode: 'open' }) this.button = document.createElement('button') this.button.type = 'button' this.button.addEventListener('click', () => { this.value = this.count + 1 this.dispatchEvent(new CustomEvent('count-change', { detail: { value: this.count }, bubbles: true, composed: true, })) }) root.append(this.button) this.value = 0 }
get value() { return this.count }
set value(next: number) { this.count = next this.button.textContent = Count: ${next} } }
if (!customElements.get('demo-counter')) { customElements.define('demo-counter', DemoCounter) }
export {} ```
The registration guard avoids defining the same tag twice. It does not replace an existing definition with new code. If you change the class during development, a full reload may be needed to test the new definition.
2. Tell TypeScript about the tag
Create app/components/elements.d.ts and ensure your TypeScript configuration includes it.
```ts import type { DetailedHTMLProps, HTMLAttributes } from 'react'
export interface CounterElement extends HTMLElement { value: number }
declare module 'react' { namespace JSX { interface IntrinsicElements { 'demo-counter': DetailedHTMLProps< HTMLAttributes<CounterElement>, CounterElement > } } } ```
This declaration describes the DOM instance and permits the tag in JSX. It does not register the element or load its JavaScript. The example assigns value through a typed ref, so it does not declare a JSX value prop.
React 19 uses a scoped JSX namespace. The React 19 upgrade guide explains how the module to augment depends on the JSX runtime. This example targets a Next.js TypeScript setup using jsx: preserve; check your actual configuration rather than copying a global namespace JSX declaration from an older tutorial.
3. Load the definition and give failure a visible state
Create app/components/CounterClient.tsx:
```tsx 'use client'
import { useEffect, useRef, useState } from 'react' import type { CounterElement } from './elements'
export default function CounterClient() { const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading') const [value, setValue] = useState(0) const element = useRef<CounterElement>(null)
useEffect(() => { let active = true // An illustrative UI deadline, not a browser or Next.js requirement. const timer = window.setTimeout(() => { active = false setPhase('error') }, 8000)
async function load() { try { await import('./demo-counter') await customElements.whenDefined('demo-counter') if (active) { window.clearTimeout(timer) setPhase('ready') } } catch { window.clearTimeout(timer) if (active) setPhase('error') } } void load()
return () => { active = false window.clearTimeout(timer) } }, [])
useEffect(() => { const node = element.current if (phase !== 'ready' || !node) return const onChange = (event: Event) => { const detail = (event as CustomEvent<{ value: number }>).detail setValue(detail.value) } node.addEventListener('count-change', onChange) node.value = 0 return () => node.removeEventListener('count-change', onChange) }, [phase])
return ( <section aria-label="Counter example" style={{ minHeight: '6rem' }}> {phase === 'loading' && <p role="status">Loading counter…</p>} {phase === 'error' && ( <div role="alert"> <p>The counter could not load. The rest of this page is available.</p> <button type="button" onClick={() => window.location.reload()}> Reload page </button> </div> )} {phase === 'ready' && ( <> <demo-counter ref={element} /> <p aria-live="polite">Last value received: {value}</p> </> )} </section> ) } ```
Import CounterClient into an App Router page and render it beneath the page's heading and instructions. The wrapper's initial server and browser output is the loading message. The element mounts only after its definition is available.
The deadline stops this UI from waiting indefinitely. It does not cancel the JavaScript download or unregister a definition that arrives later. Reload is an explicit recovery action; it is not a claim that a repeated request will repair every failure.
For a third-party widget, read its readiness contract. whenDefined() resolves when a tag has a registered definition. It does not promise that remote data, fonts, styles, or a library's asynchronous rendering have finished. If the library exposes a ready event or promise, handle that stage separately.
What about next/dynamic and external scripts?
If a React wrapper imports a browser-only library at module scope, Next.js supports dynamic(..., { ssr: false }) from a Client Component. The dynamic import must resolve to a React component. An element-registration module such as demo-counter.ts is not one.
The example above uses an effect instead, so it does not need ssr: false as a second boundary. Avoid importing the same browser-only registration module through another server-evaluated path.
For a separately hosted script bundle, track both the script load and the required tag definitions under a bounded wait. Deduplicate loading by the released asset URL. A successful network response can still leave a required tag undefined.
Content-hashed URLs help distinguish releases when the build system produces them. Files manually placed in public/ do not acquire content hashes merely because Next.js serves them. Retrying an external bundle also needs care: removing a script tag does not reliably undo execution, and the custom-element registry cannot replace a definition. Do not turn ?retry=1 into a generic recovery recipe.
React 19 properties and events
React 19's custom-element support distinguishes attributes from instance properties. On the client, a matching property is assigned as a property; otherwise React uses an attribute. During SSR, supported primitive values become attributes, while non-primitives and false are omitted.
That is why “React 19 passes every object correctly” is too broad. The element's property must be available when React performs the assignment. Registration timing still matters.
The example uses a ref after registration and pairs an event listener with cleanup. This makes the integration sequence explicit. Direct JSX event handlers are another option in React 19: preserve the event's exact case and dashes. A library's count-change event is not interchangeable with countChange.
Before using this pattern in a project
| Check | Expected result |
|---|---|
| Production build and direct route load | No browser API error during server evaluation |
| Normal interaction | The button updates and React receives one event per click |
| Slow or failed script load | A bounded loading state, then a usable error message |
| Definition never registered | The fallback appears instead of an endless blank widget |
| Route navigation and remount | No duplicated listeners or stale state updates |
| JavaScript unavailable | The page's essential information remains readable |
| Keyboard and assistive technology | Controls have usable names, focus, and feedback |
| Actual production CSP | Scripts and any shadow styles work without weakening policy to silence errors |
Choose web components when you need an existing custom-element library or a widget shared across different frontend stacks. For an interface used only inside one React application, ordinary React components may remove an unnecessary integration boundary.
If the widget's contents must be present before JavaScript runs, investigate a supported SSR approach or a useful server-rendered alternative. Hiding an undefined element with CSS can suppress a flash, but cannot create content, handle a failed load, or prove readiness.
For a failing integration, send our Next.js team the framework and library versions, the error, and the smallest route that reproduces it. That gives us a concrete starting point for separating loading, rendering, and component API problems.