Full-stack Hono + Vue 3 framework — Streaming SSR · SPA · Type-safe Loaders · SEO · Multi-runtime
- What is Vono?
- Quick start
- Manual installation
- Project structure
- Core concepts
- Routes
- Type-safe loaders & InferPageData
- usePageData()
- Composables
- Lifecycle hooks
- SEO
- Middleware
- Plugins (boot plugins)
- Layouts
- Dynamic params
- Code splitting
- SPA navigation & prefetch
- API routes
- Error handling
- Production build
- Multi-runtime deployment
- Vite plugin reference
- API reference
- How it works internally
Vono is a config-driven full-stack framework that combines Hono (server) with Vue 3 (UI). You define your routes once in a plain TypeScript array. Vono handles the rest:
- Renders on the server using Vue's streaming
renderToWebStream— the browser gets<head>(CSS, scripts) immediately while the component tree streams in. - Hydrates 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 | Detail |
|---|---|
| Streaming SSR | renderToWebStream — <head> is flushed before the body starts so the browser can parse CSS and scripts while Vue renders. |
| SPA navigation | Vue Router 4 on the client. Navigations fetch a small { state, seo, params } JSON payload — no full HTML re-render. |
| Code splitting | Pass () => import('./Page.vue') as component. Vono resolves it for SSR and wraps it in defineAsyncComponent() on the client. |
| Type-safe loaders | InferPageData<typeof page> extracts the loader's return type. usePageData<T>() returns it fully typed and reactive. |
| Full SEO | Title, description, OG, Twitter/X Cards, JSON-LD structured data — injected on SSR and DOM-synced on every SPA navigation. |
| Server middleware | Hono MiddlewareHandler — per-app, per-group, or per-route. Auth guards, rate limiting, logging. |
| Client middleware | useClientMiddleware() — runs before every SPA navigation. Auth redirects, analytics, scroll restoration. |
| Boot plugins | VonoPlugin — runs once during boot() to install stores (Pinia, etc.), register global components, or add router guards. |
| Route groups | defineGroup() shares a URL prefix, layout, and middleware across multiple routes. |
| API routes | defineApiRoute() co-locates Hono JSON endpoints alongside page routes — same file, same middleware stack. |
| Suspense support | onServerPrefetch and async setup() are fully supported via automatic <Suspense> wrapping. |
| Multi-runtime | Node.js, Bun, Deno, Cloudflare Workers, Vercel Edge — auto-detected or explicitly configured. |
| Zero config | vonoVitePlugin() orchestrates both the SSR server bundle and the client SPA bundle from one command. |
npm create @netrojs/vono@latest my-app
cd my-app
npm run devOpen http://localhost:5173.
npm install @netrojs/vono hono vue vue-router @vue/server-renderer
npm install -D vite @vitejs/plugin-vue @hono/vite-dev-server @hono/node-server typescript vue-tscmy-app/
├── app/
│ ├── layouts/
│ │ └── RootLayout.vue # Layout components (must render <slot />)
│ ├── pages/
│ │ ├── home.vue
│ │ └── blog/
│ │ └── [slug].vue # Dynamic segment
│ └── routes.ts # All route definitions
├── app.ts # createVono() — Hono app factory
├── client.ts # boot() — browser hydration entry
├── server.ts # serve() — production server entry
├── vite.config.ts
└── tsconfig.json
Browser request
│
▼
Hono catches all routes (app.ts)
│
▼
Route matching (Vono path matcher)
│
▼
Server middleware chain
│
▼
loader(ctx) ──────────────────────► typed TData object
│
▼
renderToWebStream(Vue SSR app + Suspense)
│ ▲
├──► streams <head> │ onServerPrefetch hooks awaited here
│ (CSS, scripts) │ async setup() awaited here
│ │
└──► streams <body> … ───────┘
Client boots:
createSSRApp() hydrates DOM
window.__VONO_STATE__ seeds reactive page data (zero fetch)
Vue Router takes over navigation
SPA navigation:
fetch /path {x-vono-spa: 1} ──► { state, seo, params } JSON
updatePageData(state) ──► reactive re-render
syncSEO(seo) ──► DOM <head> update
All routes are defined in a single TypeScript array. Pass it to both createVono() (server) and boot() (client).
// app/routes.ts
import { definePage, defineGroup, defineLayout, defineApiRoute } from '@netrojs/vono'
import RootLayout from './layouts/RootLayout.vue'
export const rootLayout = defineLayout(RootLayout)
export const routes = [
homePage,
blogListPage,
blogPostPage,
defineGroup({ ... }),
defineApiRoute('/api/posts', app => { ... }),
]definePage({
path: '/blog/[slug]', // Vono [param] syntax
layout: rootLayout, // override or omit to inherit
middleware: [authGuard], // optional server-side middleware
seo: (data, params) => ({ // static object or function
title: `${data.post.title} — Blog`,
}),
loader: async (c) => { // runs on the server before every render
const slug = c.req.param('slug')
return { post: await fetchPost(slug) }
},
component: () => import('./pages/blog/[slug].vue'), // code-split
})Loader context c is a Hono Context — you have access to c.req, c.res, cookies, headers, environment bindings, etc.
Group multiple routes under a shared prefix, layout, and middleware:
defineGroup({
prefix: '/dashboard',
layout: dashboardLayout,
middleware: [authGuard], // runs before every route in the group
routes: [
definePage({ path: '', component: () => import('./pages/dashboard/index.vue') }),
definePage({ path: '/posts', component: () => import('./pages/dashboard/posts.vue') }),
definePage({ path: '/settings',component: () => import('./pages/dashboard/settings.vue') }),
],
})Wraps a Vue component as a Vono layout. The component must render <slot /> where page content will appear.
import RootLayout from './layouts/RootLayout.vue'
export const rootLayout = defineLayout(RootLayout)<!-- layouts/RootLayout.vue -->
<template>
<header>…</header>
<main><slot /></main>
<footer>…</footer>
</template>Set layout: false on a page or group to render with no layout:
definePage({ path: '/login', layout: false, component: LoginPage })Co-locate Hono API handlers with your page routes:
defineApiRoute('/api/posts', (app) => {
app.get('/', (c) => c.json({ posts }))
app.post('/', async (c) => {
const body = await c.req.json()
// ...
return c.json(newPost, 201)
})
app.delete('/:id', async (c) => {
// ...
return c.json({ deleted: true })
})
})Define the type once — in the loader. Derive it everywhere else.
// app/routes.ts
export const postPage = definePage({
path: '/blog/[slug]',
loader: async (c) => ({
post: await fetchPost(c.req.param('slug')),
relatedPosts: await fetchRelated(c.req.param('slug')),
}),
component: () => import('./pages/blog/[slug].vue'),
})
// Export the inferred type — zero duplication
export type PostData = InferPageData<typeof postPage>
// PostData = { post: Post; relatedPosts: Post[] }// pages/blog/[slug].vue
import type { PostData } from '../routes'
const data = usePageData<PostData>()
// data.post is typed as Post ✅
// data.relatedPosts is typed as Post[] ✅Access the current page's typed, reactive loader data from any component:
import { usePageData } from '@netrojs/vono/client'
import type { HomeData } from '../routes'
const data = usePageData<HomeData>()
// data is readonly, reactive, and fully typedThe object updates in-place on every SPA navigation. Reactive derivations (computed, watch, template bindings) re-render automatically without the component unmounting.
const postCount = computed(() => data.posts.length) // reactive ✅
watch(() => data.user, u => console.log('user changed', u)) // reactive ✅Must be called inside setup() or <script setup>.
All composables are importable from @netrojs/vono/client.
Typed wrapper around useRoute().params:
// route: /blog/[slug]
const { slug } = useParams<{ slug: string }>()A readonly Ref<boolean> that is true while an SPA navigation is in flight:
const navigating = useNavigating()
// <div v-if="navigating" class="progress-bar" />Reactively override <head> meta from inside any component. Accepts a plain object or a reactive factory:
// Static
useMeta({ title: 'My Page', description: 'Hello world' })
// Reactive — re-runs whenever post changes
const post = computed(() => data.post)
useMeta(() => ({
title: post.value?.title ?? 'Loading…',
description: post.value?.excerpt,
ogImage: post.value?.coverImage,
}))No-op during SSR — server-side meta is controlled by the loader's seo option.
Programmatic navigation usable outside Vue component trees:
import { navigate } from '@netrojs/vono/client'
// From an event handler, store action, etc.
await navigate('/dashboard')
await navigate({ path: '/search', query: { q: 'vono' } })Throws if called before boot().
All Vue 3 lifecycle hooks are re-exported from @netrojs/vono/client:
import {
onMounted,
onBeforeMount,
onUnmounted,
onBeforeUnmount,
onUpdated,
onBeforeUpdate,
onActivated, // inside <KeepAlive>
onDeactivated, // inside <KeepAlive>
onErrorCaptured, // intercept errors from children
onServerPrefetch, // SSR-only async data fetch
onRenderTracked, // dev-mode debugging
onRenderTriggered, // dev-mode debugging
} from '@netrojs/vono/client'onServerPrefetch — async hook awaited before renderToString / renderToWebStream completes. Use it to fetch data that cannot go in the loader (e.g. inside a Pinia store):
<script setup lang="ts">
import { onServerPrefetch } from '@netrojs/vono/client'
import { usePostStore } from '../stores/posts'
const store = usePostStore()
// Runs on the server before the page is streamed
onServerPrefetch(async () => {
await store.fetchPosts()
})
</script>Requirement:
onServerPrefetchrequires the component tree to be wrapped in<Suspense>. Vono adds this wrapper automatically in bothrenderPage()(server) andboot()(client), so you do not need to add it yourself.
Use the seo option on definePage():
// Static
definePage({
seo: {
title: 'Home — My App',
description: 'The best app ever.',
ogImage: 'https://myapp.com/og/home.png',
twitterCard: 'summary_large_image',
},
...
})
// Dynamic (function receives loader output + URL params)
definePage({
seo: (data, params) => ({
title: `${data.post.title} — Blog`,
description: data.post.excerpt,
ogImage: data.post.coverImage,
canonical: `https://myapp.com/blog/${params.slug}`,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: data.post.title,
datePublished: data.post.date,
},
}),
...
})Set defaults in createVono() and boot(). Per-page values override them:
createVono({
seo: {
ogSiteName: 'My App',
ogType: 'website',
twitterCard: 'summary_large_image',
robots: 'index, follow',
},
...
})Use useMeta() for dynamic meta that depends on client-only state:
useMeta(() => ({ title: `${unreadCount.value} notifications — Dashboard` }))interface SEOMeta {
// Core
title?: string
description?: string
keywords?: string
author?: string
robots?: string
canonical?: string
themeColor?: string
// Open Graph
ogTitle?: string
ogDescription?: string
ogImage?: string
ogImageAlt?: string
ogUrl?: string
ogType?: string
ogSiteName?: string
// Twitter / X Cards
twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player'
twitterSite?: string
twitterCreator?: string
twitterTitle?: string
twitterDescription?: string
twitterImage?: string
twitterImageAlt?: string
// Structured data
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>
}Server middleware uses Hono's standard MiddlewareHandler signature. Applied per-app, per-group, or per-route.
import type { HonoMiddleware } from '@netrojs/vono'
// Per-route auth guard
const authGuard: HonoMiddleware = async (c, next) => {
const session = getCookie(c, 'session')
if (!session) {
const isSPA = c.req.header('x-vono-spa') === '1'
return isSPA
? c.json({ error: 'Unauthorized' }, 401)
: c.redirect('/login')
}
await next()
}
// Per-app logging
const logger: HonoMiddleware = async (c, next) => {
const start = Date.now()
await next()
console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${Date.now() - start}ms`)
}
createVono({
middleware: [logger], // runs on every request
routes: [
defineGroup({
prefix: '/dashboard',
middleware: [authGuard], // runs on every route in the group
routes: [...],
}),
definePage({
path: '/admin',
middleware: [authGuard, adminOnly], // runs on this route only
...
}),
],
})Client middleware runs on every SPA navigation, before the JSON fetch. Register it before boot():
import { useClientMiddleware } from '@netrojs/vono/client'
// Auth redirect
useClientMiddleware(async (to, next) => {
if (to.startsWith('/dashboard') && !isLoggedIn()) {
await navigate('/login')
return // abort — do not call next()
}
await next()
})
// Analytics
useClientMiddleware(async (to, next) => {
await next() // wait for navigation to complete
analytics.pageView(to)
})
boot({ routes })VonoPlugin functions run once during boot(), after the Vue app and Vue Router are created but before the app is mounted. Use them to install stores, register global components, or add router guards.
import { boot } from '@netrojs/vono/client'
import { createPinia } from 'pinia'
import type { VonoPlugin } from '@netrojs/vono/client'
const piniaPlugin: VonoPlugin = ({ app }) => {
app.use(createPinia())
}
const routerGuardPlugin: VonoPlugin = ({ router }) => {
router.beforeEach((to, from, next) => {
// global guard
next()
})
router.afterEach((to) => {
analytics.pageView(to.fullPath)
})
}
boot({
routes,
plugins: [piniaPlugin, routerGuardPlugin],
})Use [param] for named segments and [...param] for catch-alls:
// Single segment
definePage({ path: '/blog/[slug]', ... })
definePage({ path: '/users/[id]', ... })
// Catch-all
definePage({ path: '/docs/[...path]', ... })
// matches /docs/getting-started/installationAccess params in loaders:
loader: async (c) => {
const slug = c.req.param('slug') // typed as string
const path = c.req.param('path') // typed as string
return { post: await fetchPost(slug) }
}Access params in components:
const { slug } = useParams<{ slug: string }>()
// or
const route = useRoute()
const slug = route.params.slug as stringPass an async factory instead of a direct component reference to get per-route code splitting automatically:
// ✅ Code-split — only loaded when the route is first visited
component: () => import('./pages/blog/[slug].vue')
// ✗ Not split — bundled into the main chunk
import SlugPage from './pages/blog/[slug].vue'
component: SlugPageOn the server, Vono resolves the async import before rendering so SSR always outputs the full HTML. On the client, the chunk is lazy-loaded after hydration.
After hydration, all internal navigation is handled by Vue Router without a full page reload. Vono intercepts route changes in router.beforeEach(), fetches the JSON payload, updates the reactive store, and syncs the <head> meta — all in one guard.
Prefetch on hover (default true) warms the fetch cache before the user clicks:
boot({ routes, prefetchOnHover: true })Manual prefetch:
import { prefetch } from '@netrojs/vono/client'
prefetch('/blog/my-post')
// or call it in onMounted for a known next pageThe fetch cache is bounded at 50 entries (LRU eviction) to prevent unbounded memory growth.
defineApiRoute() mounts a Hono sub-app at the given path. It runs independently from the SSR page handler.
defineApiRoute('/api/posts', (app) => {
// GET /api/posts
app.get('/', (c) => c.json({ posts: POSTS }))
// GET /api/posts/:slug
app.get('/:slug', (c) => {
const post = POSTS.find(p => p.slug === c.req.param('slug'))
return post ? c.json(post) : c.json({ error: 'Not found' }, 404)
})
// POST /api/posts
app.post('/', async (c) => {
const body = await c.req.json<{ title: string }>()
// validate + persist ...
return c.json(newPost, 201)
})
})Global app middleware (e.g. auth, rate limiting) is forwarded to every API sub-app automatically.
Vono wraps every SSR render in try/catch. In development, rendering errors are shown as a styled HTML page with the full stack trace. In production, a plain 500 Internal Server Error is returned.
To add a custom error page for unmatched routes, pass notFound to createVono():
import NotFoundPage from './app/pages/404.vue'
createVono({ routes, notFound: NotFoundPage })The notFound component is SSR-rendered and served with HTTP 404.
npm run build
# Outputs:
# dist/server/server.js — SSR bundle (ESM, Node 18+, top-level await)
# dist/assets/ — client SPA chunks + .vite/manifest.jsonnpm start
# Runs: node dist/server/server.jsserve() auto-detects the runtime at startup. You can also set it explicitly.
// server.ts
import { serve } from '@netrojs/vono/server'
import { vono } from './app'
await serve({ app: vono, port: 3000 })
// runtime is auto-detected as 'node'Install: npm install @hono/node-server
bun dist/server/server.jsBun's Bun.serve() is used automatically. Static files are served via Bun.file() — no additional dependencies needed.
// package.json (generated by create-vono for Bun)
{
"devDependencies": {
"@types/bun": "latest"
}
}deno run -A dist/server/server.jsDeno's Deno.serve() is used automatically. Static files are read with Deno.readFile().
Export vono.handler directly and configure your platform's entry point to call it:
// worker.ts (Cloudflare Workers)
import { createVono } from '@netrojs/vono/server'
import { routes } from './app/routes'
const vono = createVono({ routes })
export default { fetch: vono.handler }Note: Edge runtimes do not support
serve(). Usevono.handlerinstead.
import { vonoVitePlugin } from '@netrojs/vono/vite'
vonoVitePlugin({
serverEntry?: string // default: 'server.ts'
clientEntry?: string // default: 'client.ts'
serverOutDir?: string // default: 'dist/server'
clientOutDir?: string // default: 'dist/assets'
serverExternal?: string[] // extra packages external to the server bundle
vueOptions?: object // forwarded to @vitejs/plugin-vue (client build only)
})Plugin order in vite.config.ts must be:
plugins: [
vue(), // 1. transforms .vue SFCs
vonoVitePlugin(), // 2. orchestrates the dual build
devServer({ entry: 'app.ts', injectClientScript: false }), // 3. dev proxy
]| 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 route |
compilePath(path) |
Compile a Vono path to { re, keys } |
matchPath(cp, pathname) |
Match a compiled path against a URL |
toVueRouterPath(path) |
Convert Vono [param] syntax to Vue Router :param syntax |
resolveRoutes(routes, opts) |
Flatten the routes tree into pages and APIs |
isAsyncLoader(value) |
Return true if value is an async component factory |
SPA_HEADER |
'x-vono-spa' |
STATE_KEY |
'__VONO_STATE__' |
SEO_KEY |
'__VONO_SEO__' |
DATA_KEY |
Symbol.for('vono:data') |
| Export | Description |
|---|---|
createVono(config) |
Create the Vono Hono app — returns { app, handler } |
serve(opts) |
Start the HTTP server (auto-detects runtime) |
detectRuntime() |
Return 'bun' | 'deno' | 'node' | 'edge' |
vonoVitePlugin(opts) |
Vite plugin for dual-bundle production build |
| Export | Description |
|---|---|
boot(options) |
Hydrate and boot the Vue SPA |
usePageData<T>() |
Reactive, typed page loader data |
useParams<T>() |
Typed URL params |
useNavigating() |
Readonly<Ref<boolean>> — true during SPA navigation |
useMeta(seo) |
Reactively override <head> meta |
navigate(to) |
Programmatic navigation |
prefetch(url) |
Warm the SPA data cache |
syncSEO(seo) |
Manually sync SEO meta to the DOM |
useClientMiddleware(mw) |
Register a client navigation middleware |
| All Vue lifecycle hooks | onMounted, onServerPrefetch, etc. |
| Vue reactivity | ref, reactive, computed, watch, watchEffect, nextTick |
| Vue Router | useRoute, useRouter, RouterLink, RouterView |
- Vite starts in dev mode with
@hono/vite-dev-serverproxying all requests to the Hono app (app.ts). - For each page request, Vono calls
renderToString()(buffered, not streaming) because Vite's Connect pipeline cannot flush aReadableStream. The buffered string is returned viac.html(). - The client entry (
client.ts) is served as a Vite dev module — HMR is fully functional.
vite buildruns withvonoVitePluginactive.- The plugin sets
build.ssr = 'server.ts'andtarget = 'node18', producingdist/server/server.js— an ESM bundle with top-level await enabled. - In the
closeBundlehook, the plugin callsbuild()again for the client entry, producingdist/assets/with a.vite/manifest.json. serve()reads the manifest and injects the hashed asset URLs into every SSR HTML shell.
- A fresh Vue app + router is created for every request to prevent cross-request state pollution (Vue SSR best practice).
- The component tree is wrapped in
<Suspense>to enableonServerPrefetchandasync setup(). - Memory history is initialised at the request URL before the router is created, eliminating Vue Router's "No match found for '/'" startup warning on non-root routes.
- The entire handler is wrapped in
try/catch. Errors always produce a valid HTTP response — in dev with a full stack trace, in production with a plain 500.
createSSRApp()is used instead ofcreateApp()so Vue hydrates existing DOM rather than re-rendering.- The current route's async chunk is pre-loaded before
app.mount()to ensure the client VDOM matches the SSR HTML. - The
<Suspense>wrapper inboot()mirrors the server'srenderPage(), preventing hydration mismatches on pages withasync setup(). - Reactive page data is stored in a single module-level
reactive({})object that is updated in-place on each navigation, ensuring computed values and watchers stay live across routes.
MIT © Netro Solutions