@llui/vike

Vike SSR/SSG adapter for LLui. Server-side rendering with client hydration, or static site generation via prerendering.

pnpm add @llui/vike

Setup

Use sub-path imports to keep jsdom out of the client bundle:

// pages/+onRenderHtml.ts
export { onRenderHtml } from '@llui/vike/server'
// pages/+onRenderClient.ts
export { onRenderClient } from '@llui/vike/client'

Custom Document Template

Use createOnRenderHtml to control the full HTML document — add stylesheets, meta tags, favicons:

// pages/+onRenderHtml.ts
import { createOnRenderHtml } from '@llui/vike/server'

export const onRenderHtml = createOnRenderHtml({
  document: ({ html, state, pageContext }) => `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <div id="app">${html}</div>
    <script>window.__LLUI_STATE__ = ${state}</script>
  </body>
</html>`,
})

Custom Container

Use createOnRenderClient to configure the mount container or add lifecycle hooks:

// pages/+onRenderClient.ts
import { createOnRenderClient } from '@llui/vike/client'

export const onRenderClient = createOnRenderClient({
  container: '#root',
  onMount: () => console.log('Page ready'),
})

How It Works

Server (onRenderHtml)

Renders the component to HTML via renderToString(). Automatically initializes jsdom for server-side DOM (lazy-loaded to avoid client bundle pollution). Serializes state into a <script> tag for hydration.

Client (onRenderClient)

Hydrates the server-rendered HTML on the client. Attaches event listeners and reactive bindings to existing DOM nodes without re-rendering. Falls back to fresh mountApp() for client-side navigations.

API

Export Sub-path Description
onRenderHtml @llui/vike/server Default server hook — minimal HTML template
createOnRenderHtml @llui/vike/server Factory for custom document templates
onRenderClient @llui/vike/client Default client hook — hydrate or mount
createOnRenderClient @llui/vike/client Factory for custom container/lifecycle

The barrel export (@llui/vike) re-exports everything, but prefer sub-path imports to avoid bundling jsdom into the client.

Functions

onRenderHtml()

Default onRenderHtml hook — no layout, minimal document template, jsdom-backed DOM env. For Cloudflare Workers (no jsdom support) or a custom layout / document, use createOnRenderHtml({ domEnv, … }) with linkedomEnv from @llui/dom/ssr/linkedom. The lazy import below keeps jsdom out of the client bundle — Rollup's graph walker only pulls it when this server hook executes.

function onRenderHtml(pageContext: PageContext): Promise<RenderHtmlResult>

createOnRenderHtml()

Factory to create a customized onRenderHtml hook. Do not name your layout file +Layout.ts. Vike reserves +Layout for its own framework-adapter config (vike-react / vike-vue / vike-solid) and will conflict with @llui/vike's Layout option. Name the file Layout.ts, app-layout.ts, or anywhere outside /pages that Vike won't scan, and import it here by path.

// pages/+onRenderHtml.ts
import { createOnRenderHtml } from '@llui/vike/server'
import { AppLayout } from './Layout.js' // ← NOT './+Layout'
export const onRenderHtml = createOnRenderHtml({
  Layout: AppLayout,
  document: ({ html, state, head }) => `<!DOCTYPE html>
    <html><head>${head}<link rel="stylesheet" href="/styles.css" /></head>
    <body><div id="app">${html}</div>
    <script>window.__LLUI_STATE__ = ${state}</script></body></html>`,
})
function createOnRenderHtml(options: RenderHtmlOptions): (pageContext: PageContext) => Promise<RenderHtmlResult>

_renderChain()

Render every layer of the chain into one composed DOM tree, then serialize. At each non-innermost layer, consume the pending pageSlot() registration and insert the next layer's nodes as siblings after the anchor comment, bracketed by an end sentinel. Scopes are threaded so inner layers inherit the outer layer's scope tree for context lookups. @internal — exported for unit testing only (_renderChain).

function _renderChain(chain: LayoutChain, chainData: readonly unknown[], env: DomEnv): { html: string; envelope: HydrationEnvelope }

fromTransition()

Adapt a TransitionOptions object (e.g. the output of routeTransition() from @llui/transitions, or a preset like fade / slide) into the onLeave / onEnter pair expected by createOnRenderClient.

import { createOnRenderClient, fromTransition } from '@llui/vike/client'
import { routeTransition } from '@llui/transitions'
export const onRenderClient = createOnRenderClient({
  Layout: AppLayout,
  ...fromTransition(routeTransition({ duration: 200 })),
})

The transition operates on the slot element — in a no-layout setup, the root container; in a layout setup, the innermost surviving layer's pageSlot() element. Opacity / transform fades apply to the outgoing page content, then the new page fades in.

function fromTransition(t: TransitionOptions): Pick<RenderClientOptions, 'onLeave' | 'onEnter'>

_resetChainForTest()

@internal — test helper. Disposes every layer in the current chain and clears the module state so subsequent calls behave as a first mount. Not part of the public API; subject to change without notice.

function _resetChainForTest(): void

_resetCurrentHandleForTest()

Back-compat alias for the pre-layout test helper name. @internal @deprecated — use _resetChainForTest instead.

function _resetCurrentHandleForTest(): void

onRenderClient()

Default onRenderClient hook — no layout, no animation hooks. Hydrates on first load, mounts fresh on subsequent navs. Use createOnRenderClient for the customizable factory form.

function onRenderClient(pageContext: ClientPageContext): Promise<void>

createOnRenderClient()

Factory to create a customized onRenderClient hook. See RenderClientOptions for the full option surface — this is the entry point for persistent layouts, route transitions, and lifecycle hooks. Do not name your layout file +Layout.ts. Vike reserves the + prefix for its own framework config conventions, and +Layout.ts is interpreted by vike-react / vike-vue / vike-solid framework adapters as a native layout config. @llui/vike isn't a framework adapter in that sense — it's a render adapter, and createOnRenderClient consumes the layout component directly via the Layout option. Name the file Layout.ts, app-layout.ts, or anywhere outside /pages that Vike won't scan, and import it here by path.

// pages/+onRenderClient.ts
import { createOnRenderClient, fromTransition } from '@llui/vike/client'
import { routeTransition } from '@llui/transitions'
import { AppLayout } from './Layout.js' // ← NOT './+Layout'
export const onRenderClient = createOnRenderClient({
  Layout: AppLayout,
  ...fromTransition(routeTransition({ duration: 200 })),
  onMount: () => console.log('page rendered'),
})
function createOnRenderClient(options: RenderClientOptions): (pageContext: ClientPageContext) => Promise<void>

getLayoutChain()

Public read of the current layout chain. Returns the live AppHandles for [...layouts, page], outermost first. Empty array before the first mount; updates after every navigation. Returns a fresh array each call, but the AppHandle references are shared with the live chain — calling .send() / .dispose() / .subscribe() operates on the same instance the framework manages. Prefer the onMount(chain) callback for lifecycle-coupled wiring (the framework guarantees the chain is fully populated when it fires); use this getter for ad-hoc reads where the caller can't thread state through onMount.

function getLayoutChain(): readonly AppHandle[]

_mountChainSuffix()

Mount (or hydrate) chain[startAt..end] into initialTarget, with the initial layer's rootLifetime parented at initialParentLifetime. Threads slot → next-target → next-parentLifetime through the chain. initialTarget is HTMLElement for the outermost layer (container- based mount/hydrate) and Comment for inner layers that mount relative to a pageSlot() anchor. Fails loudly if a non-innermost layer forgot to call pageSlot(), or if the innermost layer called pageSlot() unnecessarily. @internal — test helper. Exported so client-page-slot.test.ts can test anchor-mount/dispose contracts directly with hand-built DOM. Not part of the public API.

function _mountChainSuffix(chain: LayoutChain, chainData: readonly unknown[], startAt: number, initialTarget: HTMLElement | Comment, initialParentLifetime: Lifetime | undefined, opts: MountOpts): void

Interfaces

PageContext

Page context shape as seen by @llui/vike's server hook. Page and data are whichever +Page.ts and +data.ts Vike resolved for the current route; lluiLayoutData is an optional array of per-layer layout data matching the chain configured on createOnRenderHtml. data is derived from the global Vike.PageContext namespace so that consumer-side augmentations (the Vike convention for typing data) flow into this hook's callbacks without any cast. When the consumer hasn't augmented the namespace, data falls back to unknown.

export interface PageContext {
  Page: AnyComponentDef
  data?: VikePageContextData
  lluiLayoutData?: readonly unknown[]
  head?: string
}

DocumentContext

export interface DocumentContext {
  /** Rendered component HTML (layout + page composed if a Layout is configured) */
  html: string
  /** JSON-serialized hydration envelope (chain-aware when Layout is configured) */
  state: string
  /** Head content from pageContext.head (e.g. from +Head.ts) */
  head: string
  /** Full page context for custom logic */
  pageContext: PageContext
}

RenderHtmlResult

export interface RenderHtmlResult {
  documentHtml: string | { _escaped: string }
  pageContext: { lluiState: unknown }
}

RenderHtmlOptions

Options for the customized createOnRenderHtml factory. Mirrors @llui/vike/client's RenderClientOptions.Layout — the same chain shape is accepted for consistency between server and client render.

export interface RenderHtmlOptions {
  /** Custom HTML document template. Defaults to a minimal layout. */
  document?: (ctx: DocumentContext) => string

  /**
   * Persistent layout chain. One of:
   *
   * - A single `ComponentDef` — becomes a one-layout chain.
   * - An array of `ComponentDef`s — outermost first, innermost last.
   *   Every layer except the innermost must call `pageSlot()` in its view.
   * - A function that returns a chain from the current `pageContext` —
   *   enables per-route chains (e.g. reading Vike's `urlPathname`).
   *
   * The server renders the full chain as one composed HTML tree. Client
   * hydration reads the matching envelope and reconstructs the chain
   * layer-by-layer.
   */
  Layout?: AnyComponentDef | LayoutChain | ((pageContext: PageContext) => LayoutChain)

  /**
   * Factory that returns the `DomEnv` backing SSR render. Call with
   * either `jsdomEnv` (from `@llui/dom/ssr/jsdom`) or `linkedomEnv`
   * (from `@llui/dom/ssr/linkedom`). The factory is invoked once per
   * page render, so each request gets a fresh DOM — safe under
   * concurrency, no `globalThis` mutation.
   *
   * On Cloudflare Workers use `linkedomEnv` — jsdom's transitive deps
   * (whatwg-url, tr46, punycode) don't resolve under workerd.
   *
   * @example
   * ```ts
   * import { jsdomEnv } from '@llui/dom/ssr/jsdom'
   * createOnRenderHtml({ Layout: MyLayout, domEnv: jsdomEnv })
   * ```
   */
  domEnv: () => DomEnv | Promise<DomEnv>
}

ClientPageContext

Page context shape as seen by @llui/vike's client-side hooks. The Page and data fields come from whichever +Page.ts and +data.ts Vike resolved for the current route. data is derived from the global Vike.PageContext namespace — the convention users already know from Vike. Consumer augmentations of Vike.PageContext { interface PageContext { data?: MyData } } flow through to every callback here without a cast. Unaugmented projects fall back to unknown. lluiLayoutData is optional and carries per-layer data for the layout chain configured via createOnRenderClient({ Layout }). It's indexed outermost-to-innermost, one entry per layout layer. Absent entries mean the corresponding layout's init() receives undefined. Users wire this from their Vike +data.ts files by merging layout-owned data under the lluiLayoutData key.

export interface ClientPageContext {
  Page: AnyComponentDef
  data?: VikePageContextData
  lluiLayoutData?: readonly unknown[]
  isHydration?: boolean
}

RenderClientOptions

Page-lifecycle hooks that fire around the dispose → mount cycle on client navigation. With persistent layouts in play the cycle only tears down the divergent suffix of the layout chain — any layers shared between the old and new routes stay mounted. Navigation sequence for an already-mounted app:

  client nav triggered


  compare old chain to new chain → find first mismatch index K


  onLeave(leaveTarget)   ← awaited; leaveTarget is the slot element
    │                      at depth K-1 (or the root container if K=0)
    │                      whose contents are about to be replaced

  dispose chainHandles[K..end] innermost first


  leaveTarget.textContent = ''


  mount newChain[K..end] into leaveTarget, outermost first


  onEnter(leaveTarget)   ← fire-and-forget; fresh DOM in place


  onMount()

On the initial hydration render, onLeave and onEnter are NOT called — there's no outgoing page to leave and no animation to enter. Use onMount for code that should run on every render including the initial one.

export interface RenderClientOptions {
  /** CSS selector for the mount container. Default: `'#app'`. */
  container?: string

  /**
   * Persistent layout chain. One of:
   *
   * - A single `ComponentDef` — becomes a one-layout chain.
   * - An array of `ComponentDef`s — outermost layout first, innermost
   *   layout last. Every layer except the innermost must call
   *   `pageSlot()` in its view to declare where nested content renders.
   * - A function that returns a chain from the current `pageContext` —
   *   lets different routes use different chains, e.g. by reading
   *   Vike's `pageContext.urlPathname` or `pageContext.config.Layout`.
   *
   * Layers that are shared between the previous and next navigation
   * stay mounted. Only the divergent suffix is disposed and re-mounted.
   * Dialogs, focus traps, and effect subscriptions rooted in a surviving
   * layer are unaffected by the nav.
   */
  Layout?: AnyComponentDef | LayoutChain | ((pageContext: ClientPageContext) => LayoutChain)

  /**
   * Called on the slot element whose contents are about to be replaced,
   * BEFORE the divergent suffix is disposed and re-mounted. The slot's
   * current DOM is still attached when this runs — the only moment a
   * leave animation can read/write it. Return a promise to defer the
   * swap until the animation completes.
   *
   * For a plain no-layout setup, the slot element is the root container.
   * Not called on the initial hydration render.
   */
  onLeave?: (el: HTMLElement) => void | Promise<void>

  /**
   * Called after the new divergent suffix is mounted, on the same slot
   * element that was passed to `onLeave`. Use this to kick off an enter
   * animation. Fire-and-forget — promise returns are ignored.
   *
   * Not called on the initial hydration render.
   */
  onEnter?: (el: HTMLElement) => void

  /**
   * Called after mount or hydration completes. Fires on every render
   * including the initial hydration. Use for per-render side effects
   * that don't fit the animation hooks.
   *
   * Receives the live layout chain — `[...layouts, page]`, outermost
   * first — as `AppHandle`s. Consumers wiring observability bridges,
   * the LAP agent client, custom devtools, or any tool that needs
   * `getState` / `send` / `subscribe` for the *outermost layout*
   * (which `window.__lluiComponents` did not reliably expose for
   * hydrated apps until @llui/dom@0.0.31) can read it from here:
   *
   * ```ts
   * createOnRenderClient({
   *   Layout: AppLayout,
   *   onMount: (chain) => {
   *     const layout = chain[0]    // outermost layout
   *     const page = chain.at(-1)  // current page
   *   },
   * })
   * ```
   *
   * The array is a snapshot at call time; consumers should not retain
   * references to handles past the next navigation, since surviving
   * layers stay live but disposed layers do not.
   */
  onMount?: (chain: readonly AppHandle[]) => void

  /**
   * Called for each surviving layout layer whose `lluiLayoutData[i]`
   * slice changed across a client navigation. Surviving layers are
   * layers shared between the previous and current chain — they stay
   * mounted, but their state needs a fresh injection of nav-driven
   * data (pathname, breadcrumbs, session, …).
   *
   * The framework gives you the layer's `AppHandle` and the changed
   * data; you decide how to translate it into a state-update message
   * and dispatch it through `handle.send(msg)`. Typically:
   *
   * ```ts
   * createOnRenderClient({
   *   Layout: NavAwareLayout,
   *   onLayerDataChange: ({ def, handle, newData }) => {
   *     if (def === NavAwareLayout) {
   *       handle.send({ type: 'navChanged', data: newData as NavData })
   *     }
   *   },
   * })
   * ```
   *
   * Not called for layers whose data slice is unchanged (shallow-key
   * `Object.is` diff on records, whole-value `Object.is` for
   * primitives). Not called on the initial hydration render — `init`
   * handles the initial data injection there. Not called for the
   * page layer (the innermost entry); the page always disposes and
   * remounts, so its `init(data)` receives the fresh data directly.
   *
   * If omitted, surviving layers retain their existing state across
   * navigations. Opt in only when the layout needs to react to
   * nav-scoped data changes.
   */
  onLayerDataChange?: (ctx: {
    def: AnyComponentDef
    handle: AppHandle
    newData: unknown
    prevData: unknown
  }) => void

  /**
   * Forwarded to `@llui/dom`'s `hydrateApp` / `hydrateAtAnchor` for
   * every layer in the layout chain on initial hydration. When `true`,
   * effects returned by each component's `init()` are dispatched
   * post-swap on the client. When `false` (default), they are skipped
   * — the SSR pass already ran them on the server, and re-running on
   * the client typically produces duplicate fetches / subscriptions.
   *
   * Opt in only when:
   *   - `init()` returns no effects, OR
   *   - all returned effects are idempotent / client-only (e.g. attaching
   *     a `window` listener), AND
   *   - the SSR path didn't run them (typically because `init()` checks
   *     a `loaded` flag in state and returns `[]` when serverState
   *     already has the data loaded).
   *
   * Subsequent client-side navigation always uses `mountApp` /
   * `mountAtAnchor` (fresh mount), which always fires init effects
   * regardless of this flag.
   */
  runInitEffectsOnHydrate?: boolean
}