Client enhancements
Pages, layouts, and ordinary React components render complete static HTML. Choose the smallest browser boundary that fits the interaction:
- use
enhance()for event listeners, DOM state, or an imperative browser library layered onto existing HTML; - use a React island for a local interface that needs React state, effects, or hooks.
Neither choice turns the page into a client-rendered application.
Enhance an existing element
Spread enhance() directly onto the HTML element that owns the interaction:
import { enhance } from '@briansunter/nib'
export function Counter() {
return (
<button {...enhance('counter')} data-count="0" type="button">
Count: 0
</button>
)
}
Add the matching module at the folder path below src/enhancements:
// src/enhancements/counter/index.client.ts
import type { ClientEnhancement } from '@briansunter/nib'
export default ((root, signal) => {
let count = Number(root.dataset.count ?? 0)
root.addEventListener('click', () => {
count += 1
root.textContent = `Count: ${count}`
}, { signal })
}) satisfies ClientEnhancement
The module receives the marked root and its cleanup signal as positional
arguments. Scope queries to root and pass { signal } to event listeners.
Nib aborts the signal before development replacement or runtime teardown.
The name is the folder path below src/enhancements; nested IDs such as
gallery/filters map to
src/enhancements/gallery/filters/index.client.ts. Plain .js entry modules
are also supported and can default-export the function without a type helper.
Enhancements start immediately by default. Use the single optional timing when work should wait until the marked root approaches the viewport:
<section {...enhance('map', { when: 'visible' })} aria-label="Map">
Complete static fallback
</section>
There is no idle timing. enhance() returns the canonical
data-nib-enhancement and optional data-nib-when markers. The final rendered
HTML is the source of truth, so Nib validates the same marker contract in raw
HTML too. Enhancements may nest when each owns a distinct element, and cleanup
runs deepest first.
CSS imported by an enhancement module is linked only on routes that render the enhancement, including visible roots. Routes without enhancement markers omit the enhancement runtime. The runtime is DOM-only and contains no React.
React islands
Use an island when a local interactive component is naturally expressed with
React. Island modules live below src/islands, import the helper from the
dedicated React entry, and default-export the definition:
// src/islands/counter.tsx
import { useState } from 'react'
import { island } from '@briansunter/nib/react'
import './counter.css'
function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
Count: {count}
</button>
)
}
export default island(Counter, { when: 'visible' })
Import the default export into a page or layout like an ordinary component:
// src/pages/page.tsx
import Counter from '../islands/counter'
export default function HomePage() {
return <Counter initialCount={0} />
}
Nib derives the island ID from its module path, so
src/islands/cart/summary.tsx becomes cart/summary; there is no registry or
manually assigned ID. It server-renders complete initial HTML and serializes
the props needed to hydrate that root.
Island props must be JSON-serializable: strings, finite numbers, booleans,
null, arrays, and plain objects composed from those values. Functions, React
elements, dates, class instances, sparse arrays, cycles, and other non-JSON
values are rejected.
Nib owns the generated <nib-island> boundary and its data-nib-* hydration
metadata; application code renders the island definition instead of mutating
that runtime metadata.
Place an island where a custom element is valid flow content. An island cannot
sit directly inside restricted parser contexts such as table, tbody, tr,
or select; make the containing table or control subtree the island instead.
Nib fails the build when the browser would restructure the boundary before
hydration.
island(Component) hydrates on load by default. Fix the policy for every
instance with island(Component, { when: 'load' }) or
island(Component, { when: 'visible' }). There is no idle strategy. Nested
island definitions compose inside their owning React root.
Island CSS and JavaScript are route-scoped. Routes without islands omit the island runtime and browser React, so an enhancement-only site still has a zero React client.
Application-wide setup and navigation
Use the optional exact entry src/client.ts only when browser setup has no
scoped enhancement or island owner. Nib invokes its default export with one
cleanup signal:
import type { ClientInitializer } from '@briansunter/nib'
export default ((signal) => {
const reportOnline = () => {
document.documentElement.toggleAttribute('data-online', navigator.onLine)
}
reportOnline()
window.addEventListener('online', reportOnline, { signal })
window.addEventListener('offline', reportOnline, { signal })
}) satisfies ClientInitializer
The initializer may return a promise. CSS imported from src/client.ts is
application-wide. Projects without this file do not build or link that entry.
Nib does not intercept links or forms. Navigation loads each prerendered document through the browser's native behavior; there is no client router, document swapping, prefetch controller, or navigation history API.