npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@llui/vike

v0.13.1

Published

LLui Vike SSR adapter — onRenderHtml, onRenderClient hooks

Readme

@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'
import { linkedomEnv } from '@llui/dom/ssr/linkedom'

export const onRenderHtml = createOnRenderHtml({
  domEnv: linkedomEnv,
  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'),
})

Persistent Layouts

Declare app chrome (header, sidebar, dialogs, session state) as a Layout component that stays mounted across client navigation. The route-scoped Page swaps in and out at the layout's pageSlot() position while the surrounding layout subtree — and every DOM node, focus trap, portal, and effect subscription inside it — is untouched.

Do not name your layout file +Layout.ts. Vike reserves the + prefix for its own framework-adapter config conventions, and +Layout.ts specifically is interpreted by vike-react / vike-vue / vike-solid as a framework-native layout config. @llui/vike isn't a framework adapter in that sense — it's a render adapter, and createOnRenderClient({ Layout }) consumes the layout component directly. Name your file Layout.ts, app-layout.ts, or place it anywhere outside /pages that Vike won't scan, then import it from +onRenderClient.ts / +onRenderHtml.ts by path.

// pages/Layout.ts    ← not +Layout.ts
import { component, div, header, main } from '@llui/dom'
import { pageSlot } from '@llui/vike/client'

export const AppLayout = component<LayoutState, LayoutMsg>({
  name: 'AppLayout',
  init: () => [{ session: null }, []],
  update: layoutUpdate,
  view: ({ send }) => [
    div({ class: 'app-shell' }, [
      header([
        /* persistent chrome */
      ]),
      main([pageSlot()]), // ← where the route's Page renders
    ]),
  ],
})
// pages/+onRenderClient.ts
import { createOnRenderClient } from '@llui/vike/client'
import { AppLayout } from './Layout'

export const onRenderClient = createOnRenderClient({
  Layout: AppLayout,
})
// pages/+onRenderHtml.ts — server renders layout + page as one tree
import { createOnRenderHtml } from '@llui/vike/server'
import { linkedomEnv } from '@llui/dom/ssr/linkedom'
import { AppLayout } from './Layout'

export const onRenderHtml = createOnRenderHtml({
  Layout: AppLayout,
  domEnv: linkedomEnv,
})

Call pageSlot() exactly once in each layout's view, at the position where nested content should render. It's an ordinary structural primitive — composes naturally inside show(), branch(), provide(), and any other view tree.

You can place your own siblings next to pageSlot() — before it or after it — in the same parent element (e.g. a navigation-loading bar beside the page inside <main>). The slot owns only the region between its anchor and a synthesized end sentinel, so on both SSR and every client navigation it inserts/disposes that region without touching your siblings. The display: contents wrapper that older guidance used to isolate the slot is no longer needed — drop it.

import { div, main, text } from '@llui/dom'
import { pageSlot } from '@llui/vike/client'

const navBar = () => div({ class: 'nav-loading' }, [text('…')])

main([
  navBar(), // ← your sibling survives navigation…
  pageSlot(), // …only this region swaps
])

Nested layouts

Pass an array to stack layouts outer-to-inner. Each layout except the innermost calls its own pageSlot(). The innermost layer is always the route's Page.

createOnRenderClient({
  Layout: [AppLayout, DashboardLayout],
})

For per-route chains — e.g. /dashboard/* routes use [AppLayout, DashboardLayout] while /settings uses [AppLayout] — pass a resolver function instead:

createOnRenderClient({
  Layout: (pageContext) =>
    pageContext.urlPathname.startsWith('/dashboard') ? [AppLayout, DashboardLayout] : [AppLayout],
})

The resolver's pageContext exposes Vike's routing fields directly — urlPathname (a string) and routeParams (a Record<string, string>) — so you branch the chain on the route with no cast. Configure the same resolver on createOnRenderHtml so the server renders the identical chain the client hydrates. (Its type is exported as LayoutResolverContext / ServerLayoutResolverContext if you want to annotate a standalone resolver.)

The chain diff on each nav walks old and new chains in parallel and finds the first mismatch. Every layer before that mismatch stays mounted; every layer at or after it is torn down innermost-first and re-mounted outermost-first. Navigating from /dashboard/reports to /dashboard/overview only disposes the PageAppLayout and DashboardLayout stay alive. Navigating to /settings disposes DashboardLayout and the Page, keeping only AppLayout.

Route-scoped section layouts (persistent sidebar)

This is the idiomatic way to build a section with its own persistent chrome — a docs area with a left sidebar, a settings master-detail, a dashboard rail. Make the section's chrome a layout that's only in the chain for that section's routes:

// /docs/* keeps DocsLayout (the sidebar) mounted; everything else drops it.
import { jsdomEnv } from '@llui/dom/ssr/jsdom'

const Layout = (pageContext: { urlPathname: string }) =>
  pageContext.urlPathname.startsWith('/docs') ? [AppLayout, DocsLayout] : [AppLayout]

createOnRenderHtml({ Layout, domEnv: jsdomEnv })
createOnRenderClient({ Layout, ...fromTransition(routeTransition({ duration: 200 })) })

Navigating /docs/intro → /docs/advanced keeps AppLayout and DocsLayout mounted — the sidebar's DOM, scroll position, focus, and effect subscriptions are untouched — and re-mounts only the innermost article Page. Because the transition operates on the innermost surviving layer's pageSlot() container, fromTransition(...) animates just the article column, not the persistent sidebar. Navigating out of /docs disposes DocsLayout (and its sidebar) along with the page.

Layout ↔ Page communication

Layouts and pages are independent component instances with their own state, update, and send. They share state and expose cross-cutting operations via context, not via direct messaging.

The scope-tree integration makes this natural: pageSlot() creates its slot as a child of the layout's render scope, and the page's rootLifetime is parented inside that slot. useContext from within the page walks up through the slot and finds any providers the layout installed above it.

Common pattern — a layout-owned toast system:

// pages/Layout.ts
import { component, div, main, provide, createContext } from '@llui/dom'
import { pageSlot } from '@llui/vike/client'

interface ToastDispatchers {
  show: (msg: string) => void
  dismiss: (id: string) => void
}
export const ToastContext = createContext<ToastDispatchers>(
  { show: () => {}, dismiss: () => {} },
  'Toast',
)

export const AppLayout = component<LayoutState, LayoutMsg>({
  name: 'AppLayout',
  init: () => [{ toasts: [] }, []],
  update: layoutUpdate,
  view: ({ send }) => [
    div({ class: 'app-shell' }, [
      ToastStack(), // reads from layout state
      provide(
        ToastContext,
        {
          show: (msg) => send({ type: 'toast/show', msg }),
          dismiss: (id) => send({ type: 'toast/dismiss', id }),
        },
        () => [main([pageSlot()])],
      ),
    ]),
  ],
})
// Any page below the layout can now use the toast dispatcher.
// pages/studio/+Page.ts
import { component, button, text, useContext } from '@llui/dom'
import { ToastContext, type ToastDispatchers } from '../Layout'

export const StudioPage = component<StudioState, StudioMsg>({
  name: 'StudioPage',
  init: () => [{ saved: false }, []],
  update: (s, m) => {
    if (m.type === 'saveSucceeded') {
      // ...
    }
    return [s, []]
  },
  view: ({ send }) => {
    const toast: ToastDispatchers = useContext(ToastContext)
    return [button({ onClick: () => toast.show('Saved') }, [text('Save')])]
  },
})

provide(ctx, value, render) publishes value to everything render builds, and useContext(ctx) reads the nearest provided value (or the context's default). For the common case here — a stable dispatcher bag that doesn't depend on the parent's state — pass the closure-captured send-backed object directly, as above. The same pair also carries reactive context values that DO depend on state: because a value may itself be a Signal, pass a derived signal rather than an accessor callback (e.g. provide(ThemeContext, state.map((s) => s.theme), () => [...])). Use this for toast queues, session managers, breadcrumb dispatchers, and any other pattern where a page calls into layout-owned operations.

Toast state machines, global progress indicators, breadcrumb/title bars, modal-takeover chrome toggles, and session-expired banners all fall out of this pattern naturally — the layout owns the state, provides a dispatcher via context, and any page can trigger layout operations without touching the layout's internals.

For the rarer case where a layout needs to probe a page (e.g. "is your form dirty? can we navigate away?"), use addressed effects — the page registers an address on mount, the layout dispatches a targeted effect to it.

Layout data

Layouts can have their own server-fetched data alongside per-page +data.ts by populating pageContext.lluiLayoutData as an array matching the layout chain (outermost first).

In the signal runtime init() takes no arguments, so a layer's data slice is used as that layer's initial state; init() runs only when the slice is undefined. Type the slice as the layer's State — there is no init(layoutData) hook to receive it:

// lluiLayoutData[0] IS AppLayout's state.
type AppLayoutState = { user: string; unread: number }

export const AppLayout = component<AppLayoutState, AppLayoutMsg>({
  name: 'AppLayout',
  // Runs only when lluiLayoutData[0] is absent (e.g. a client-side mount for a
  // route whose data hook didn't populate it).
  init: () => ({ user: 'anonymous', unread: 0 }),
  update: (s) => s,
  view: () => [pageSlot()],
})

You must list lluiLayoutData in Vike's passToClient. Vike forwards a pageContext key to the browser only when it is declared there. Without it the key exists during SSR and is undefined on the client, so the server renders the real data and the client re-seeds the same layout from init():

// pages/+config.ts
export default {
  passToClient: ['lluiLayoutData'],
}

The adapter no longer lets that pass silently — hydration throws and names the layer (see Hydration manifest below). Add any other custom pageContext key your layers read to the same list; data and routeParams are already passed by Vike itself.

Hydration manifest

window.__LLUI_STATE__ carries a small integrity manifest, not per-layer state:

window.__LLUI_STATE__ = {
  v: 3,
  layers: ['AppLayout', 'DashboardLayout', 'ReportsPage'],
  seeded: [true, false, true],
}

The server render runs no effects — it only bakes each layer's initial state into HTML — so every layer's seed is exactly data ?? init(). Shipping the full state again would be dead weight, so the client reconstructs each seed locally and the script ships only the chain's layer names plus, per layer, where the seed came from.

That last part is load-bearing rather than decorative. The client can only reconstruct a data-seeded layer if the data actually reached the browser, and it does so only when the key is in passToClient. seeded[i] records whether the SERVER used a data slice for layer i; hydration compares it against what the client has and throws — naming the layer and the fix — when the two disagree in either direction. Comparing layer names alone could not see this: the names match perfectly while every layout silently reverts to init().

The manifest is versioned (v) and lists every layer outermost → page. On hydration the client verifies it against the chain it's about to build: a wrong version, a wrong layer count, a divergent layer at any index, or a seed that reached one side only throws a clear error instead of silently binding the wrong instance. Unnamed layers use a stable per-index key (layer:0, layer:1, …), normalized identically on both sides, so an unnamed page or layout hydrates cleanly.

The chain's data array is built index-aligned with the chain, so a short or missing lluiLayoutData can never slide the page's +data slice onto a layout.

init() must be deterministic

Because no state is shipped, a layer with no data slice is re-seeded on the client by calling its own init() again. That is only sound if init() returns the same value in both places. It is a hard precondition of SSR under this adapter:

// ✗ renders one state on the server and hydrates a different one
init: () => ({ id: crypto.randomUUID(), openedAt: Date.now() })

// ✓ deterministic — the client re-seed reproduces the server's state exactly
init: () => ({ id: null, openedAt: null })

Anything varying — Date.now(), Math.random(), crypto.randomUUID(), a module-level counter, a mutable module binding — must come from somewhere else:

  • emit it from an effect so it is produced once, after mount, through a message; or
  • resolve it server-side and pass it in through the layer's data slice, so both sides read the same value.

Dev builds check this for you and warn, naming the layer. The server calls each init()-seeded layer's init() a second time and compares (catching counters and Math.random()), and records a hash of the resulting state in the manifest so the client can compare its own re-seed against it (catching the time-dependent cases, which look perfectly stable within one server tick). Both checks are gated on the dev build: production emits no hashes and makes no extra init() call.

Both compare JSON hashes, so they reach exactly as far as LLui's JSON-serializable State invariant does: state carrying a Set, a Map, a function or an undefined-valued property is already invalid, and a value JSON.stringify drops or flattens can vary between server and client while still hashing identically — init: () => ({ tags: new Set([++n]) }) slips past both checks. Keep state JSON-serializable and the determinism check means what it says; break that invariant and it is the least of what breaks (devtools time-travel, @llui/test replay and agent snapshots go with it).

This is a warning rather than a thrown error — by the time it is observable the state divergence has already happened, the app is running, and the fix is in your init().

Page Transitions

createOnRenderClient accepts onLeave and onEnter hooks that fire around the dispose-and-remount cycle on client navigation. onLeave is awaited — return a promise to defer the swap until a leave animation finishes:

import { createOnRenderClient, fromTransition } from '@llui/vike/client'
import { routeTransition } from '@llui/transitions'

export const onRenderClient = createOnRenderClient({
  ...fromTransition(routeTransition({ duration: 200 })),
})

fromTransition adapts any TransitionOptions (the shape returned by routeTransition, fade, slide, etc.) into the hook pair. The transition operates on the container element — its opacity / transform fades out the outgoing page, then the new page fades in after mount.

onLeave/onEnter are not loading hooks. They bracket the DOM swap, which runs after Vike has already fetched the new page's +data — Vike only invokes onRenderClient once the incoming pageContext is populated. Nothing in this cycle covers the during-fetch latency a user perceives as lag. For a loader that appears the moment a navigation starts (on the click, before the round-trip), use Navigation Progress below.

For raw animations without @llui/transitions, write the hooks yourself:

export const onRenderClient = createOnRenderClient({
  onLeave: async (el) => {
    await el.animate({ opacity: [1, 0] }, 200).finished
  },
  onEnter: (el) => el.animate({ opacity: [0, 1] }, 200),
})

Navigation Progress

To show a loader while a client navigation is in flight — the latency between the click and the new page appearing — you need a signal at navigation start, before the +data round-trip. None of the onRenderClient hooks fire there (see the note above). Vike's native onPageTransitionStart / onPageTransitionEnd hooks do, and createNavigationProgress() wraps them into a reactive boolean the layout binds:

// nav-progress.ts — your module, created once
import { createNavigationProgress } from '@llui/vike/client'

// `delay` debounces the reveal: navigations that resolve faster than 120ms
// (e.g. served from a hover prefetch) never flash the indicator.
export const navProgress = createNavigationProgress({ delay: 120 })

@llui/vike can't register Vike's +onPageTransition* hooks for you — Vike discovers them by the + filename convention — so re-export the handle's hook functions from the two convention files:

// pages/+onPageTransitionStart.ts
export { onPageTransitionStart } from '../nav-progress'
// pages/+onPageTransitionEnd.ts
export { onPageTransitionEnd } from '../nav-progress'

Then bind navProgress.pending in the layout. It's a LiveSignal<boolean>: peek() for a one-shot read, bind(cb) for a reactive subscription that fires immediately with the current value and on every change. The zero-message path is an onMount that toggles a class — bind returns its unsubscribe, which doubles as the onMount cleanup:

// pages/Layout.ts
import { component, div, header, main, onMount } from '@llui/dom'
import { pageSlot } from '@llui/vike/client'
import { navProgress } from '../nav-progress'

export const AppLayout = component<LayoutState, LayoutMsg>({
  name: 'AppLayout',
  init: () => [{ session: null }, []],
  update: layoutUpdate,
  view: () => [
    div({ class: 'app-shell' }, [
      onMount((root) => navProgress.pending.bind((p) => root.classList.toggle('nav-pending', p))),
      header([]),
      main([pageSlot()]),
    ]),
  ],
})

This replaces the module-singleton + layout-handle capture + hand-rolled nav/pending message + reducer case each app would otherwise re-derive. If you'd rather drive the indicator from layout state instead of a class toggle, bind straight into a send({ type: 'nav/pending', pending }) — but the class toggle needs no message at all.

Client Navigation Lifecycle

When Vike fires a client-side navigation, @llui/vike runs this sequence inside onRenderClient:

  1. onLeave(el) — awaited. The outgoing page's DOM is still mounted; this is the only moment where a leave animation can read/write it.
  2. currentHandle.dispose() — tears down the outgoing component's scope tree. All onMount cleanups run here, portals are removed from their targets, focus traps are popped, body scroll locks release, sibling aria-hidden is restored. The regression test in @llui/components/test/components/dialog-dispose.test.ts covers this path explicitly.
  3. el.textContent = '' — the outgoing DOM is cleared from the container.
  4. mountApp(el, Page, data) — the new page mounts.
  5. onEnter(el) — synchronous; fire-and-forget. Promises are ignored here.
  6. onMount() — legacy hook, fires last on every render (including the initial hydration).

On the initial hydration render, onLeave and onEnter are both skipped — there's no outgoing page to leave, and hydration doesn't insert new DOM that needs an enter animation.

AbortSignal semantics for in-flight effects. When a component is disposed, its AbortController fires and inst.signal.aborted becomes true. Effect handlers should guard their send() calls against signal.aborted — the base package already does this in @llui/effects. Network requests that have already been accepted by the server are NOT cancelled by navigation; cancellation only applies to future send() dispatches into the now-aborted instance. This is intentional: cancelling a successful signup POST just because the user clicked a nav link would lose data.

Scroll position is the host's problem. Vike controls scroll-to-top behavior via scrollToTop in +config.ts. @llui/vike doesn't touch scroll — if you need custom scroll handling, configure it on the Vike side.

Cloudflare Workers

Two things differ from a Node deploy:

  1. Pick linkedomEnv for SSR — jsdom's transitive deps (whatwg-url, tr46, punycode) don't resolve under workerd. Pass it to createOnRenderHtml:

    // pages/+onRenderHtml.ts
    import { createOnRenderHtml } from '@llui/vike/server'
    import { linkedomEnv } from '@llui/dom/ssr/linkedom'
    
    export const onRenderHtml = createOnRenderHtml({ domEnv: linkedomEnv })
  2. Guard the manual server-entry import in worker.ts. @brillout/vite-plugin-server-entry's auto-importer doesn't reach into workerd — its generated loadServerEntry() resolves a deeply-nested filesystem path that workerd refuses to bind. The fix is to import dist/server/entry.mjs yourself, but you must guard that import with import.meta.env.PROD:

    // worker.ts
    if (import.meta.env.PROD) {
      // @ts-expect-error — generated by `vite build`, absent in dev
      await import('../dist/server/entry.mjs')
    }
    
    import { renderPage } from 'vike/server'
    
    export default {
      async fetch(req: Request): Promise<Response> {
        const { httpResponse } = await renderPage({ urlOriginal: req.url })
        if (!httpResponse) return new Response('Not Found', { status: 404 })
        return new Response(httpResponse.body, {
          status: httpResponse.statusCode,
          headers: httpResponse.headers,
        })
      },
    }

    Use import.meta.env.PROD, not process.env.NODE_ENV — workerd has no Node process, so the brillout README's if (process.env.NODE_ENV === 'production') snippet evaluates falsy at runtime and silently skips the import, leaving Vike with no registered hooks. Vite substitutes import.meta.env.PROD at build time (true in vite build, false in vite dev), so the guarded branch is correctly elided in dev.

    Why the guard matters. Without it, this sequence breaks dev:

    pnpm build      # writes dist/server/entry.mjs
    pnpm dev        # workerd resolves the literal '../dist/server/entry.mjs'

    dist/server/entry.mjs calls setGlobalContext_prodBuildEntry() at module top level. Vike correctly detects a prod initializer running in dev and throws [vike@…][Bug] You stumbled upon a Vike bug. If you've already hit this, rm -rf dist && pnpm dev recovers; adding the guard prevents recurrence.

How It Works

Server (onRenderHtml)

Renders the component to HTML via renderToString(). Each render gets a fresh DomEnv from the factory passed to createOnRenderHtml({ domEnv }) — use jsdomEnv from @llui/dom/ssr/jsdom for Node targets, or linkedomEnv from @llui/dom/ssr/linkedom for Cloudflare Workers (jsdom's transitive deps don't resolve under workerd). The default onRenderHtml export wires up jsdom for zero-config Node setups; createOnRenderHtml requires an explicit domEnv factory so the bundler can tree-shake whichever DOM you don't use.

Client (onRenderClient)

Hydrates the server-rendered HTML on the client via hydrateSignalApp(). It does not attach listeners to the existing server DOM in place — it builds a fresh client tree against serverState (matching the SSR render) and atomically swaps it in, replacing the server HTML. The server markup stays visible until the swap, so there's no flash. init()'s effects are skipped by default (they already ran on the server). Falls back to a 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 + persistent layouts | | onRenderClient | @llui/vike/client | Default client hook — hydrate or mount | | createOnRenderClient | @llui/vike/client | Factory for custom container + layouts + transition hooks | | pageSlot | @llui/vike/client | Structural primitive — declares where a layout renders its page | | fromTransition | @llui/vike/client | Adapter: TransitionOptions{ onLeave, onEnter } hook pair | | createNavigationProgress | @llui/vike/client | Reactive pending signal + +onPageTransition* hooks for a during-fetch loader |

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