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

@netrojs/vono

v0.1.1

Published

Full-stack Hono + Vue 3 framework — Streaming SSR, SPA, code splitting, SEO, middleware

Readme

◈ Vono

Full-stack Hono + Vue 3 framework — Streaming SSR · SPA · Code Splitting · Type-safe Loaders · SEO · TypeScript

npm license


Table of contents


What is Vono?

Vono is a file-free, config-driven full-stack framework that glues Hono (server) to Vue 3 (UI). You define your routes once in a plain TypeScript array. Vono:

  1. Renders them on the server using Vue's streaming renderToWebStream — the browser gets <head> (CSS, scripts) immediately while the body streams in.
  2. Hydrates them in the browser as a Vue 3 SPA — subsequent navigations fetch only a small JSON payload and swap the reactive data in-place, no full reload.
  3. Infers types from your loader all the way through to the component — one definition, zero duplication.

Feature matrix

| Feature | Detail | |---|---| | Streaming SSR | renderToWebStream<head> is flushed before the body starts, so the browser can parse CSS and begin JS evaluation while Vue is still rendering. Lower TTFB than buffered SSR. | | SPA navigation | Vue Router 4 on the client. Navigations send x-vono-spa: 1 and receive a small JSON { state, seo, params } payload — no HTML re-render. | | Code splitting | Pass () => import('./Page.vue') as component. Vono resolves the import before SSR and wraps it in defineAsyncComponent on the client for lazy loading. | | Type-safe loaders | definePage<TData>() infers TData from your loader. InferPageData<typeof page> extracts it for use in components. usePageData<T>() returns it fully typed and reactive. | | Full SEO | Per-page title, description, Open Graph, Twitter Cards, JSON-LD structured data — injected into <head> on SSR and synced via the DOM on SPA navigation. | | Server middleware | Hono MiddlewareHandler — applied per-app, per-group (defineGroup), or per-route. Ideal for auth, rate limiting, logging. | | Client middleware | useClientMiddleware() — runs on SPA navigation before the data fetch. Ideal for auth guards, analytics, scroll restoration. | | Route groups | defineGroup() shares a URL prefix, layout, and middleware stack across multiple pages. | | API routes | defineApiRoute() co-locates Hono JSON endpoints alongside your page routes — same file, same middleware. | | Multi-runtime | serve() auto-detects Node.js, Bun, Deno. Edge runtimes (Cloudflare Workers, Vercel Edge) use vono.handler directly. | | Zero config | One Vite plugin (vonoVitePlugin) orchestrates both the SSR server bundle and the client SPA bundle. |


How it works

Browser request
      │
      ▼
 Hono (server.ts)
      │  matches route
      ▼
 loader(ctx)  ──────────────────────────► typed TData object
      │
      ▼
 renderToWebStream(Vue SSR app)
      │
      ├──► streams <head> immediately  ──► browser parses CSS + scripts
      │
      └──► streams <body> …           ──► browser renders progressive HTML
                                            │
                                      client.ts boots
                                            │
                                      createSSRApp() hydrates DOM
                                            │
                                      window.__VONO_STATE__ seeds
                                      reactive page data (zero fetch)
                                            │
                                      Vue Router takes over navigation
                                            │
                                 SPA nav ──► fetch JSON ──► update reactive data

Quick start

npm create @netrojs/vono@latest my-app
cd my-app
npm install
npm run dev

Or with Bun:

bun create @netrojs/vono@latest my-app
cd my-app
bun install
bun run dev

Manual installation

npm i @netrojs/vono vue vue-router @vue/server-renderer hono
npm i -D vite @vitejs/plugin-vue @hono/vite-dev-server @hono/node-server vue-tsc typescript

File structure

my-app/
├── app.ts               ← createVono() + default export for dev server
├── server.ts            ← Production server entry (await serve(...))
├── client.ts            ← Browser hydration entry (boot(...))
├── vite.config.ts
├── tsconfig.json
├── global.d.ts          ← Window augmentation for SSR-injected keys
└── app/
    ├── routes.ts         ← All route definitions (pages, groups, APIs)
    ├── layouts/
    │   └── RootLayout.vue
    ├── pages/
    │   ├── home.vue
    │   ├── blog/
    │   │   ├── index.vue
    │   │   └── [slug].vue
    │   └── dashboard/
    │       └── index.vue
    └── style.css

Routes

All routes are defined in a plain TypeScript array and passed to createVono() and boot().

definePage()

The core building block. Every page is a definePage() call.

import { definePage } from '@netrojs/vono'

export const homePage = definePage({
  // URL path — supports [param] and [...catchAll] syntax
  path: '/',

  // Hono middleware applied only to this route (runs before the loader)
  middleware: [logRequest],

  // Server-side data fetcher — return value is typed and passed to usePageData()
  loader: async (c) => ({
    posts: await db.posts.findMany(),
    user:  c.get('user'),       // access Hono context variables
  }),

  // Static SEO object OR a function that receives (loaderData, params)
  seo: (data, params) => ({
    title:       `${data.posts.length} posts — My Blog`,
    description: 'The latest posts from our blog.',
    ogType:      'website',
  }),

  // Layout override for this specific page
  layout: myLayout,   // or `false` to disable the app-level layout

  // Vue component — use () => import() for automatic code splitting
  component: () => import('./pages/home.vue'),
})

// Export the inferred type for use in components
export type HomeData = InferPageData<typeof homePage>

Loader context (LoaderCtx) is the full Hono Context object — you have access to c.req, c.env, c.get() / c.set(), c.redirect(), response helpers, and anything set by upstream middleware.

defineGroup()

Groups share a URL prefix, a layout, and a middleware stack.

import { defineGroup } from '@netrojs/vono'

export const dashboardGroup = defineGroup({
  prefix:     '/dashboard',
  layout:     dashboardLayout,
  middleware: [requireAuth],   // applied to every child route
  routes: [
    definePage({ path: '',        component: () => import('./pages/dashboard/index.vue') }),
    definePage({ path: '/posts',  component: () => import('./pages/dashboard/posts.vue') }),
    definePage({ path: '/users',  component: () => import('./pages/dashboard/users.vue') }),
  ],
})
  • Child paths are concatenated: prefix /dashboard + path /posts/dashboard/posts.
  • Use path: '' (empty string) for the index route of a group (/dashboard).
  • Groups can be nested.

defineLayout()

Wraps a Vue component as a Vono layout. The component must render <slot /> where the page content goes.

import { defineLayout } from '@netrojs/vono'
import RootLayout from './layouts/RootLayout.vue'

export const rootLayout = defineLayout(RootLayout)

Pass it to createVono({ layout: rootLayout }) for an app-wide default, to defineGroup({ layout }) for a section, or directly to definePage({ layout }) for a single page. Set layout: false on a page to opt out of any inherited layout.

defineApiRoute()

Co-locate a Hono JSON API alongside your page routes. The callback receives a Hono sub-app mounted at path.

import { defineApiRoute } from '@netrojs/vono'

export const postsApi = defineApiRoute('/api/posts', (app, globalMiddleware) => {
  app.get('/',       (c) => c.json({ posts: await db.posts.findMany() }))
  app.get('/:slug',  (c) => c.json(await db.posts.findBySlug(c.req.param('slug'))))
  app.post('/',      requireAuth, async (c) => {
    const body = await c.req.json()
    return c.json(await db.posts.create(body), 201)
  })
})

API routes are registered on the Hono app before the catch-all page handler, so they always take priority.


Type-safe loaders

The loader function's return type is inferred automatically:

export const postPage = definePage({
  path:      '/blog/[slug]',
  loader:    async (c) => {
    const post = await db.findPost(c.req.param('slug'))
    return { post, related: await db.relatedPosts(post.id) }
  },
  component: () => import('./pages/blog/[slug].vue'),
})

TypeScript infers TData = { post: Post; related: Post[] } from the loader automatically. The full chain is type-safe: server loader → SSR render → window.__VONO_STATE__usePageData<T>() in the component.

InferPageData<T>

Extract the loader type from an exported page definition — your single source of truth:

// routes.ts
export const postPage = definePage({ loader: async () => ({ post: ... }), ... })
export type PostData = InferPageData<typeof postPage>
//          ^ { post: Post }  — derived from the loader, never written twice

// pages/blog/[slug].vue
import type { PostData } from '../routes'
const data = usePageData<PostData>()
//    ^ fully typed reactive object

This pattern means you never manually maintain a parallel type — change the loader and TypeScript propagates the error to every component immediately.


usePageData()

Available inside any component rendered inside a Vono route:

import { usePageData } from '@netrojs/vono/client'
import type { PostData } from '../routes'

const data = usePageData<PostData>()
// data.post     → typed Post
// data.related  → typed Post[]

The returned object is reactive — when Vue Router performs a SPA navigation, Vono fetches the new JSON payload and updates the reactive store in-place. Components re-render automatically without being unmounted, preserving scroll position and any local state.

Calling usePageData() outside of a component setup() throws a clear error.


State hydration & lifecycle hooks

Vono performs full SSR hydration. This means:

  1. The server renders the complete HTML string and injects loader data as window.__VONO_STATE__.
  2. boot() calls createSSRApp() (not createApp()), which tells Vue to hydrate the existing DOM rather than re-render from scratch.
  3. Vue's reactivity system is activated on the existing DOM nodes — no flicker, no double render.
  4. All Vue lifecycle hooks work exactly as expected after hydration:
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePageData } from '@netrojs/vono/client'

const data   = usePageData<MyData>()
const route  = useRoute()
const router = useRouter()

// ref / computed / watch — all work as normal
const count   = ref(0)
const doubled = computed(() => count.value * 2)
watch(() => data.title, (t) => document.title = t)

// onMounted fires after hydration on the client (not on the server)
// Safe to access DOM APIs, start timers, attach event listeners
onMounted(() => {
  console.log('Hydrated!', document.title)
})

onUnmounted(() => {
  // Clean up subscriptions, timers, etc.
})
</script>

Key rules:

  • onMounted and DOM APIs are client-only — they are never called during SSR. This is standard Vue SSR behaviour.
  • ref, computed, watch, provide/inject all work in both SSR and client contexts.
  • useRoute() and useRouter() work after hydration because boot() installs the Vue Router instance into the app before mounting.
  • Do not access window, document, or localStorage outside of onMounted (or if (import.meta.env.SSR) guards) — they are undefined on the server.

SEO

Define SEO per page, either as a static object or as a function that receives the loader data and URL params:

// Static
definePage({
  seo: {
    title:       'Home — My Site',
    description: 'Welcome to my site.',
    ogTitle:     'Home',
    ogImage:     'https://my-site.com/og/home.png',
    twitterCard: 'summary_large_image',
    jsonLd: {
      '@context': 'https://schema.org',
      '@type':    'WebSite',
      name:       'My Site',
      url:        'https://my-site.com',
    },
  },
  ...
})

// Dynamic — function receives (loaderData, params)
definePage({
  seo: (data, params) => ({
    title:       `${data.post.title} — My Blog`,
    description: data.post.excerpt,
    ogType:      'article',
    ogImage:     `https://my-site.com/og/${params.slug}.png`,
    canonical:   `https://my-site.com/blog/${params.slug}`,
  }),
  ...
})

Global defaults are set in createVono({ seo: { ... } }) and merged with per-page values (page wins on any key they both define).

On SPA navigation, syncSEO() is called automatically to update document.title and all <meta> tags in-place.

Supported fields:

| Field | HTML output | |---|---| | title | <title> | | description | <meta name="description"> | | keywords | <meta name="keywords"> | | author | <meta name="author"> | | robots | <meta name="robots"> | | canonical | <link rel="canonical"> | | themeColor | <meta name="theme-color"> | | ogTitle, ogDescription, ogImage, ogUrl, ogType, ogSiteName, ogImageAlt | <meta property="og:…"> | | twitterCard, twitterSite, twitterTitle, twitterDescription, twitterImage | <meta name="twitter:…"> | | jsonLd | <script type="application/ld+json"> |


Middleware

Server middleware

Vono server middleware is a standard Hono MiddlewareHandler:

import type { HonoMiddleware } from '@netrojs/vono'

const requireAuth: HonoMiddleware = async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token || !verifyToken(token)) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  // Pass user to downstream handlers via Hono context
  c.set('user', decodeToken(token))
  await next()
}

const logRequest: HonoMiddleware = async (c, next) => {
  const start = Date.now()
  await next()
  console.log(`${c.req.method} ${c.req.path} → ${Date.now() - start}ms`)
}

Three levels of application:

// 1. App-wide (runs before every page and API route)
createVono({ middleware: [logRequest], routes })

// 2. Per group (runs for every route inside the group)
defineGroup({ middleware: [requireAuth], prefix: '/dashboard', routes: [...] })

// 3. Per page (runs only for that specific route)
definePage({ middleware: [rateLimit], path: '/api/expensive', ... })

Middleware is executed in order: app → group → route. Return early (without calling next()) to short-circuit the chain.

Client middleware

Runs on every SPA navigation before the JSON data fetch:

import { useClientMiddleware } from '@netrojs/vono/client'

// Call before boot() — typically in client.ts
useClientMiddleware(async (url, next) => {
  // Auth guard — redirect to login if session expired
  if (url.startsWith('/dashboard') && !isLoggedIn()) {
    await navigate('/login')
    return  // don't call next() — cancels the navigation
  }

  // Analytics
  analytics.track('pageview', { url })

  await next()  // proceed with the navigation
})

Layouts

A layout is a Vue component that wraps page content via <slot />:

<!-- layouts/RootLayout.vue -->
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>

<template>
  <div class="app">
    <nav>
      <RouterLink to="/">Home</RouterLink>
      <RouterLink to="/blog">Blog</RouterLink>
    </nav>
    <main>
      <slot />   <!-- page content renders here -->
    </main>
    <footer>© 2025</footer>
  </div>
</template>

Register and apply it:

// routes.ts
export const rootLayout = defineLayout(RootLayout)

// App-wide default
createVono({ layout: rootLayout, routes })

// Per-section override
defineGroup({ layout: dashboardLayout, prefix: '/dashboard', routes: [...] })

// Per-page override
definePage({ layout: false, ... })  // disables layout for this page

Dynamic params

Use bracket syntax in paths. Params are available in loader, seo, and components via useRoute():

// Single param
definePage({ path: '/blog/[slug]', loader: (c) => ({ post: db.findPost(c.req.param('slug')) }) })

// Multiple params
definePage({ path: '/user/[id]/post/[postId]', loader: (c) => ({
  user: db.findUser(c.req.param('id')),
  post: db.findPost(c.req.param('postId')),
}) })

// Catch-all (matches /files/a/b/c → params.path = 'a/b/c')
definePage({ path: '/files/[...path]', loader: (c) => ({ path: c.req.param('path') }) })

Inside a component:

<script setup lang="ts">
import { useRoute } from 'vue-router'  // re-exported from @netrojs/vono/client
const route = useRoute()
// route.params.slug — string
</script>

Code splitting

Every page with component: () => import('./pages/X.vue') generates a separate JS chunk. Vono handles the split correctly in both environments:

  • Server (SSR): isAsyncLoader() detects the factory, awaits the import, and renders the resolved component synchronously.
  • Client (hydration): The current route's chunk is pre-loaded before app.mount() to guarantee the client VDOM matches the SSR HTML. All other route chunks are lazy-loaded on demand via defineAsyncComponent.

No configuration needed — just use dynamic imports.


SPA navigation & prefetch

After hydration, Vue Router handles all same-origin navigation. Vono's router.beforeEach hook intercepts every navigation and:

  1. Sends GET <url> with x-vono-spa: 1 header.
  2. The server recognises the header and returns { state, seo, params } JSON (skipping SSR entirely).
  3. The reactive page data store is updated in-place — components re-render reactively.
  4. syncSEO() updates all meta tags.

Prefetch on hover (enabled by default) warms the fetch cache before the user clicks:

boot({ routes, prefetchOnHover: true })

Manual prefetch:

import { prefetch } from '@netrojs/vono/client'
prefetch('/blog/my-post')

API routes

API routes are standard Hono apps mounted at the given path:

export const usersApi = defineApiRoute('/api/users', (app) => {
  app.get('/',    async (c) => c.json(await db.users.findMany()))
  app.post('/',   requireAuth, async (c) => {
    const body = await c.req.json<{ name: string; email: string }>()
    return c.json(await db.users.create(body), 201)
  })
  app.delete('/:id', requireAuth, async (c) => {
    await db.users.delete(c.req.param('id'))
    return c.body(null, 204)
  })
})

The Hono sub-app is mounted before the page handler catch-all, so API routes always win. You can call your own API from loader() or from the client using fetch().


Production build

npm run build

This runs vite build which triggers vonoVitePlugin:

  1. SSR bundledist/server/server.js (ES module, target: node18, top-level await enabled, all dependencies externalised).
  2. Client bundledist/assets/ (ES module chunks + .vite/manifest.json for asset fingerprinting).
npm run start
# node dist/server/server.js

The production server reads the manifest, injects the correct hashed script and CSS URLs, and serves static assets from dist/assets/.

Why target: 'node18' matters

The SSR bundle uses await serve(...) at the top level. esbuild's default browser targets (chrome87, es2020, etc.) do not support top-level await, causing the build to fail with:

Top-level await is not available in the configured target environment

vonoVitePlugin explicitly sets target: 'node18' for the SSR build, which tells esbuild to emit ES2022+ syntax — including top-level await — in the output.


Multi-runtime deployment

Node.js

// server.ts
import { serve } from '@netrojs/vono/server'
import { vono } from './app'

await serve({ app: vono, port: 3000, runtime: 'node' })

Bun

await serve({ app: vono, port: 3000, runtime: 'bun' })

Deno

await serve({ app: vono, port: 3000, runtime: 'deno' })

Cloudflare Workers / Edge

// worker.ts — export the handler; no serve() call
import { vono } from './app'
export default { fetch: vono.handler }

Vercel Edge

// api/index.ts
import { vono } from '../../app'
export const config = { runtime: 'edge' }
export default vono.handler

Vite plugin reference

// vite.config.ts
import { vonoVitePlugin } from '@netrojs/vono/vite'

vonoVitePlugin({
  serverEntry:    'server.ts',   // default
  clientEntry:    'client.ts',   // default
  serverOutDir:   'dist/server', // default
  clientOutDir:   'dist/assets', // default
  serverExternal: ['pg', 'ioredis'],  // extra packages kept external in SSR bundle
  vueOptions:     { /* @vitejs/plugin-vue options for the client build */ },
})

The plugin:

  • On vite build: configures the SSR server bundle (target node18, externals, ESM output).
  • In closeBundle: triggers a separate build() call for the client SPA bundle with manifest enabled.

API reference

@netrojs/vono (core, isomorphic)

| Export | Description | |---|---| | definePage(def) | Define a page route | | defineGroup(def) | Define a route group | | defineLayout(component) | Wrap a Vue component as a layout | | defineApiRoute(path, register) | Define a Hono API sub-app | | compilePath(path) | Compile a Vono path to a RegExp + keys | | matchPath(compiled, pathname) | Match a pathname against a compiled path | | toVueRouterPath(path) | Convert [param] syntax to :param syntax | | isAsyncLoader(fn) | Detect an async component loader | | InferPageData<T> | Extract loader data type from a PageDef | | SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY | Shared constants |

@netrojs/vono/server

| Export | Description | |---|---| | createVono(options) | Create the Hono app + streaming SSR handler | | serve(options) | Start the server on Node / Bun / Deno | | detectRuntime() | Auto-detect the current JS runtime | | vonoVitePlugin(options) | Vite plugin for dual-bundle production builds |

@netrojs/vono/client

| Export | Description | |---|---| | boot(options) | Hydrate the SSR HTML and mount the Vue SPA | | usePageData<T>() | Access the current page's loader data (reactive) | | useClientMiddleware(fn) | Register a client-side navigation middleware | | prefetch(url) | Warm the SPA data cache for a URL | | syncSEO(seo) | Imperatively sync SEO meta tags | | useRoute() | Vue Router's useRoute (re-exported) | | useRouter() | Vue Router's useRouter (re-exported) | | RouterLink | Vue Router's RouterLink (re-exported) |

@netrojs/vono/vite

| Export | Description | |---|---| | vonoVitePlugin(options) | Same as the server export — convenience alias |


How SSR hydration works internally

Understanding this prevents subtle bugs:

On the server, for each request Vono:

  1. Matches the URL against compiled route patterns.
  2. Runs server middleware, then the loader.
  3. Creates a fresh createSSRApp() + createRouter() per request — no shared state between requests (critical for correctness in concurrent environments).
  4. Initialises createMemoryHistory() at the request URL before constructing the router. This prevents Vue Router from emitting [Vue Router warn]: No match found for location with path "/" — the warning fires when the router performs its startup navigation to the history's initial location (/) before any routes match.
  5. Awaits router.isReady(), then calls renderToWebStream() to stream HTML.
  6. Injects window.__VONO_STATE__, __VONO_PARAMS__, and __VONO_SEO__ as inline <script> tags in the <body>.

On the client, boot():

  1. Reads the injected window.__VONO_STATE__[pathname] and seeds a module-level reactive store — no network request on first load.
  2. Calls createSSRApp() (not createApp()), which tells Vue to hydrate (adopt) the existing server-rendered DOM.
  3. Installs readonly(reactiveStore) as DATA_KEY into the Vue app via provide()usePageData() reads from here.
  4. Pre-loads the current route's async component chunk synchronously (before mount()) to ensure the client VDOM matches the SSR HTML byte-for-byte, preventing hydration mismatches.
  5. Mounts the app — Vue reconciles the virtual DOM against the real DOM without re-rendering anything.
  6. On subsequent SPA navigations, router.beforeEach fetches JSON, updates the reactive store in-place, and calls syncSEO().