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:
// 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:
// 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.
@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 returnedrouterfunction is a standard(request: Request) => Response | Promise<Response>and can be passed directly toBun.serve,Deno.serve, or exported as a Cloudflare Worker'sfetchhandler.defineRoute(path, handler)— declares a single route. The handler receives aRouteContextand must return aResponse.json(data, init?)— constructs aResponsewithContent-Type: application/jsonand the given data serialized. A thin convenience wrapper overnew Response(JSON.stringify(data), ...).
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:
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):
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:
@route {
name: users
ssr: true
}// 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 tohost. Used for pure client-side rendering (no prior SSR output).hydrate(component, host, snapshot)— attaches reactive effects to existing DOM nodes underhostwithout re-creating elements. It walks the arbor node tree and usesdata-aihu-pathattributes 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).
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:
@agentblocks are removed from the output. The JS contains:// [client build] @agent block elided.$servermacro references are removed. The JS contains:// [client build] $server macro reference elided.manifest_jsoninEmitResultis empty.
This ensures zero server-only code reaches the browser bundle.