G · 13 guides

Guides / SSR & Hydration

SSR and Hydration

aihu supports server-side rendering via @aihu/server. An app's build mode is set with output in the inline viteAihuPlugin({...}) config — 'spa' (client-rendered) or 'static' (pages prerendered to HTML). Server-side rendering itself is provided by @aihu/server, whose legacy build config additionally exposes a build.target of 'client', 'server', or 'universal'.

Build mode and targets

The app build mode is set inline in vite.config.ts via output:

governedtypescripttypescript210 B
// vite.config.ts
import { viteAihuPlugin } from '@aihu/app'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    viteAihuPlugin({ output: 'static' }), // 'spa' | 'static'
  ],
})
Mode Description
spa Client-rendered single-page app. Default.
static Pages prerendered to static HTML at build time.

For server-side rendering, @aihu/server's legacy config additionally exposes a build.target, set via a standalone aihu.config.ts that default-exports defineAihuConfig. This file still works as a fallback:

governedtypescripttypescript162 B
// aihu.config.ts (legacy fallback)
import { defineAihuConfig } from '@aihu/server'

export default defineAihuConfig({
  build: {
    target: 'universal',
  },
})
Target Description
client Browser bundle only. @agent manifest and $server refs are elided.
server Server bundle only. Full agent manifest and server-only code included.
universal Both client and server outputs. Default.

The @route block and route sidecars

The Rust compiler emits a .route.json sidecar alongside each compiled .aihu file. This sidecar encodes the route path, name, SSR mode, and loader reference. At build time, viteRouterIntegration() reads every .route.json in src/pages/ and assembles the route manifest into the virtual:aihu-routes virtual module. The result is a fully static manifest — no filesystem scanning at runtime.

governedaihuaihu51 B
@route {
  path: /users
  name: users
  ssr: true
}

Setting ssr: true enables server-side rendering for that route.

createRequestRouter, defineRoute, and json()

@aihu/server provides a fetch-API-native router. These three exports are the core building blocks:

  • createRequestRouter(options) — builds a fetch-API request handler from an explicit route manifest. The returned router function is a standard (request: Request) => Response | Promise<Response> and can be passed directly to Bun.serve, Deno.serve, or exported as a Cloudflare Worker's fetch handler.
  • defineRoute(path, handler) — declares a single route. The handler receives a RouteContext and must return a Response.
  • json(data, init?) — constructs a Response with Content-Type: application/json and the given data serialized. A thin convenience wrapper over new Response(JSON.stringify(data), ...).
governedtypescripttypescript690 B
import { createRequestRouter, defineRoute, json } from '@aihu/server'
import { createAgentReadinessRoutes } from '@aihu-plugin/agent-readiness'

const ar = createAgentReadinessRoutes({
  name: 'My App',
  endpoint: 'https://myapp.workers.dev/mcp',
  summary: 'An aihu-powered app.',
})

const router = createRequestRouter({
  routes: [
    defineRoute('/llms.txt', ar.llmsTxt),
    defineRoute('/.well-known/mcp/server-card.json', ar.mcpServerCard),
    defineRoute('/robots.txt', ar.robotsTxt),
    defineRoute('/api/hello', () => json({ hello: 'world' })),
  ],
})

// Cloudflare Worker
export default { fetch: router }
// Bun
// Bun.serve({ fetch: router })
// Deno
// Deno.serve(router)

Additional server utilities from @aihu/server: badRequest(), notFound(), serverError(), methodNotAllowed(), defineApiRoute(), composeMiddleware(), defineMiddleware().

renderToStream and renderToString

Stream-render a component to an HTML response:

governedtypescripttypescript141 B
import { renderToStream } from '@aihu/server'

const response = renderToStream(MyComponent, {
  props: { userId: 42 },
  loader: myLoader,
})

Returns a ReadableStream<string> that emits HTML chunks as the component tree resolves. Suitable for edge runtimes and Node.js streaming responses.

For a complete HTML string (e.g. for pre-rendering):

governedtypescripttypescript165 B
import { renderToString } from '@aihu/server'

const html = await renderToString(async () => {
  const data = await myLoader(ctx)
  return renderMyComponent(data)
})

SSR with loaders

Enable SSR per route with ssr: true in the @route block. The server runs the associated defineLoader and injects the result as props before streaming the component:

governedaihuaihu36 B
@route {
  name: users
  ssr: true
}
governedtypescripttypescript164 B
// users.loader.ts
import { defineLoader } from '@aihu/server'

export const loader = defineLoader(async (ctx) => {
  return { users: await db.users.findMany() }
})

The loader result is serialized into the SSR payload and dehydrated on the client — no second fetch needed.

Islands

In aihu, "islands" means interactive components embedded in an otherwise static or server-rendered page. The SSR output is inert HTML; each island component re-attaches its reactive signal graph on the client using hydrate() rather than mount().

The island pattern gives you SSR performance for the outer shell while preserving full reactivity for interactive regions — without downloading or executing JavaScript for the static parts.

hydrate() vs mount() — the distinction

Both return a MountScope (with .dispose() and .serialize() methods), but they ship from different entry points: mount from @aihu/arbor, hydrate from @aihu/arbor/hydrate. The split keeps hydration code out of client-only bundles, which never call it. They differ in what happens to the DOM:

  • mount(component, host) — creates DOM elements from scratch and appends them to host. Used for pure client-side rendering (no prior SSR output).
  • hydrate(component, host, snapshot) — attaches reactive effects to existing DOM nodes under host without re-creating elements. It walks the arbor node tree and uses data-aihu-path attributes on pre-rendered elements as anchors to wire signal bindings. If a path anchor is missing (DOM mismatch), that subtree falls back to full _materialize().

hydrate() is the right choice when the server has already emitted HTML for a component. The snapshot parameter is the pre-parsed JSON state previously emitted by MountScope.serialize() (typically injected as window.aihu_state[tag] by the SSR renderer).

governedtypescripttypescript235 B
import { hydrate } from '@aihu/arbor/hydrate'

// In the browser, for an SSR-rendered island:
const scope = hydrate(
  () => buildCounterTree(),
  document.querySelector('live-counter'),
  window.__aihu_state__['live-counter'] ?? {},
)

Client-build elision

When target is client:

  • @agent blocks are removed from the output. The JS contains: // [client build] @agent block elided.
  • $server macro references are removed. The JS contains: // [client build] $server macro reference elided.
  • manifest_json in EmitResult is empty.

This ensures zero server-only code reaches the browser bundle.