Package Exports
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@netrojs/create-vono) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
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().