Server data, domain state

Fetch in a Server Component, pass the resolved value to a small Client Component, then initialize the domain with useDomain.hydrate(). The rest of the UI reads the same domain hook it uses for client interactions.

Server Component → resolved data → client route adapter → domain hook → UI

For a query that should render inside a streamed <Suspense> boundary, the experimental .suspend(arg) path runs the query during the server render and streams its result to the browser cache. Both paths start with the provider below.

Provider setup

Next.js owns request data and the HTML stream, so the provider receives the framework functions it needs as props. Nothing else about the provider changes; both props are optional.

// app/providers.tsx
'use client'

import { ComwitProvider } from '@comwit/state'
import { useRouter, useServerInsertedHTML } from 'next/navigation'
import type { ReactNode } from 'react'
import { getServerSearchParams } from './get-server-search-params'

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()

  return (
    <ComwitProvider
      context={{ router }}
      getServerSearchParams={getServerSearchParams}
      useServerInsertedHTML={useServerInsertedHTML}
      defaultOptions={{ query: { staleTime: 30_000 } }}
    >
      {children}
    </ComwitProvider>
  )
}
// app/get-server-search-params.ts
import { workUnitAsyncStorage } from 'next/dist/server/app-render/work-unit-async-storage.external.js'

/** Request search for searchParam() fields. Prerendering has no request, so the browser initializes. */
export function getServerSearchParams(): string | null {
  if (typeof window !== 'undefined') return null
  const store = workUnitAsyncStorage.getStore()
  return store?.type === 'request' ? store.url.search : null
}
PropServerBrowser
getServerSearchParamsRead once when the provider initializes; seeds searchParam() fieldsNever called; hydration reads the transferred value
useServerInsertedHTMLInserts a JSON script with resolved .suspend() results ahead of each flushed chunkNo-op; hydrating selectors read those scripts

The request store above is an internal Next.js module. It is the only synchronous way to read the request URL from a Client Component's server render, so pin it per Next.js version and keep the getter in one file. useServerInsertedHTML is a public export of next/navigation; the same slot accepts any function with that signature that inserts the returned element before the next flush.

1. Define the query

The query function is used for later client loads or action-driven refetches. The initial server seed does not invoke it.

// state/product/index.ts
import { create, model, query } from '@comwit/state'

export type Product = { id: string; title: string; description: string }

const product = model({
  detail: query<Product | null, string>({
    initialData: null,
    staleTime: 30_000,
    queryFn: async (id) => {
      const response = await fetch(`/api/products/${encodeURIComponent(id)}`)
      if (response.status === 404) return null
      if (!response.ok) throw new Error('Could not load product')
      return (await response.json()) as Product
    },
  }),
})

export const useProduct = create(product, { actions: [] })

2. Fetch on the server

getProduct below is your server-side API or database function returning Product | null.

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct } from '@/api/products'
import { ProductRoute } from './product-route'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const initialProduct = await getProduct(id)
  if (!initialProduct) notFound()

  return <ProductRoute id={id} initialProduct={initialProduct} />
}

3. Hydrate before the domain read

// app/products/[id]/product-route.tsx
'use client'

import { useProduct, type Product } from '@/state/product'

export function ProductRoute({ id, initialProduct }: { id: string; initialProduct: Product }) {
  useProduct.hydrate({ detail: { arg: id, data: initialProduct } })
  return <ProductDetail />
}

function ProductDetail() {
  const product = useProduct((s) => s.detail.data)
  if (!product) return null

  return (
    <article>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
    </article>
  )
}

Call hydrate() unconditionally, like a hook, before the normal read of that model. It returns void. The fresh entry is immediately successful, with isLoading and isFetching both false; passive readers do not issue a duplicate request.

Hydration contract

// Argument queries require their declared arg.
useProduct.hydrate({ detail: { arg: id, data: initialProduct } })

// An argument-free Query<number> takes only data.
useDashboard.hydrate({ count: { data: 42 } })

// Optional seeds keep the call unconditional.
useProduct.hydrate(seed ?? null)

Only query fields are accepted, and TypeScript infers the field names, arguments, and data. Partial entry maps are allowed. Plain state, realtime queries, and standalone local() cannot be hydrated. Infinite queries accept their data value; this API does not accept a separate cursor/history seed.

Equivalent repeated seeds are no-ops. A new unread entry initializes before its first snapshot. Changes to an observed entry apply in the requesting render's layout commit, so an abandoned transition cannot replace the currently committed screen. Hydration also records freshness for later .load() or .query() calls; .refetch() still forces a request.

Choose who owns the request

Data ownerPattern
A mounted Client Component.load(arg) and render loading/error state
A Server ComponentAwait data, then useDomain.hydrate(...)
A streamed <Suspense> boundary.suspend(arg) with useServerInsertedHTML (experimental)
A user event or coordinated workflowAn action calling .query(arg) or .refetch()
An IndexedDB-only fallbackStandalone local() with .restore(arg)

.load() does not start a request during SSR because its work begins after commit. A passive read after hydration displays the supplied server data. Adding .load(arg) makes that component an owner of subsequent client loading and freshness checks.

local.query() and local.infinite() accept the same hydration interface. Their IndexedDB work and canonical entity reconciliation begin after commit. Server and browser providers have separate memory; the explicit seed is what connects their initial renders.

Streaming Suspense (experimental)

.suspend(arg) runs queryFn during render and throws the pending Promise, so a <Suspense> boundary shows its fallback while the server waits for the data. With useServerInsertedHTML on the provider, the resolved result travels with the boundary's HTML and the browser hydrates the same data without calling queryFn again. Available in @comwit/state@2.5.0-beta.0:

npm install @comwit/state@beta
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductDetail } from './product-detail'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductDetail id={id} />
    </Suspense>
  )
}
// app/products/[id]/product-detail.tsx
'use client'

import { useProduct } from '@/state/product'

export function ProductDetail({ id }: { id: string }) {
  const product = useProduct((s) => s.detail.suspend(id))
  if (!product.data) return <NotFound />

  return (
    <article>
      <h1>{product.data.title}</h1>
      <p>{product.data.description}</p>
    </article>
  )
}
server render: suspend(id) → queryFn → boundary HTML
                                  ↘ <script type="application/json" data-comwit-suspend> before that chunk
browser: hydrate boundary → suspend(id) reads the script → same snapshot, no queryFn → entry active after commit

The streamed entry keeps the server's fetch time, so staleTime applies to later .load() and .query() calls as if the browser had fetched it. Each result is matched to the selecting hook's position in the tree, so nested providers and repeated components stay isolated. Without the provider prop, the server and browser caches are separate and the browser runs queryFn again while hydrating; that is the same as today's behavior.

Use this path only when queryFn can run in both environments:

  • It executes inside the Client Component bundle during SSR. It cannot import database code, and Next.js rejects Server Function calls during render. Call a Route Handler or an external API.
  • On the server, fetch needs an absolute URL. Build the origin from an environment variable or a request-aware helper instead of writing fetch('/api/products').
  • Results are transferred as JSON. Dates arrive as strings; convert them where the data is read.
  • Keep the <Suspense> boundary inside the provider. A server-side query failure renders the boundary's fallback and the browser retries the request with the same error boundary rules.
  • renderToString, the Pages Router, realtime queries, and streaming AsyncIterable fetchers are not covered.

hydrate() remains the default for data that must stay on the server. Choose .suspend() for public, cacheable reads whose loading state belongs to a Suspense boundary.

Do not initialize state by calling an action in a render body. Move resolved server query data to hydrate() or a streamed .suspend() and leave normal actions for events and imperative workflows.