@netrojs/vono
v0.1.1
Published
Full-stack Hono + Vue 3 framework — Streaming SSR, SPA, code splitting, SEO, middleware
Maintainers
Readme
◈ Vono
Full-stack Hono + Vue 3 framework — Streaming SSR · SPA · Code Splitting · Type-safe Loaders · SEO · TypeScript
Table of contents
- What is Vono?
- How it works
- Quick start
- Manual installation
- File structure
- Routes
- Type-safe loaders
- usePageData()
- State hydration & lifecycle hooks
- SEO
- Middleware
- Layouts
- Dynamic params
- Code splitting
- SPA navigation & prefetch
- API routes
- Production build
- Multi-runtime deployment
- Vite plugin reference
- API reference
- How SSR hydration works internally
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:
- Renders them on the server using Vue's streaming
renderToWebStream— the browser gets<head>(CSS, scripts) immediately while the body streams in. - 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.
- 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 dataQuick start
npm create @netrojs/vono@latest my-app
cd my-app
npm install
npm run devOr with Bun:
bun create @netrojs/vono@latest my-app
cd my-app
bun install
bun run devManual 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 typescriptFile 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.cssRoutes
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 objectThis 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:
- The server renders the complete HTML string and injects loader data as
window.__VONO_STATE__. boot()callscreateSSRApp()(notcreateApp()), which tells Vue to hydrate the existing DOM rather than re-render from scratch.- Vue's reactivity system is activated on the existing DOM nodes — no flicker, no double render.
- 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:
onMountedand DOM APIs are client-only — they are never called during SSR. This is standard Vue SSR behaviour.ref,computed,watch,provide/injectall work in both SSR and client contexts.useRoute()anduseRouter()work after hydration becauseboot()installs the Vue Router instance into the app before mounting.- Do not access
window,document, orlocalStorageoutside ofonMounted(orif (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 pageDynamic 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 viadefineAsyncComponent.
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:
- Sends
GET <url>withx-vono-spa: 1header. - The server recognises the header and returns
{ state, seo, params }JSON (skipping SSR entirely). - The reactive page data store is updated in-place — components re-render reactively.
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 buildThis runs vite build which triggers vonoVitePlugin:
- SSR bundle —
dist/server/server.js(ES module,target: node18, top-level await enabled, all dependencies externalised). - Client bundle —
dist/assets/(ES module chunks +.vite/manifest.jsonfor asset fingerprinting).
npm run start
# node dist/server/server.jsThe 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 environmentvonoVitePlugin 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.handlerVite 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 (targetnode18, externals, ESM output). - In
closeBundle: triggers a separatebuild()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:
- Matches the URL against compiled route patterns.
- Runs server middleware, then the loader.
- Creates a fresh
createSSRApp()+createRouter()per request — no shared state between requests (critical for correctness in concurrent environments). - 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. - Awaits
router.isReady(), then callsrenderToWebStream()to stream HTML. - Injects
window.__VONO_STATE__,__VONO_PARAMS__, and__VONO_SEO__as inline<script>tags in the<body>.
On the client, boot():
- Reads the injected
window.__VONO_STATE__[pathname]and seeds a module-level reactive store — no network request on first load. - Calls
createSSRApp()(notcreateApp()), which tells Vue to hydrate (adopt) the existing server-rendered DOM. - Installs
readonly(reactiveStore)asDATA_KEYinto the Vue app viaprovide()—usePageData()reads from here. - 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. - Mounts the app — Vue reconciles the virtual DOM against the real DOM without re-rendering anything.
- On subsequent SPA navigations,
router.beforeEachfetches JSON, updates the reactive store in-place, and callssyncSEO().
