mikser-io-sdk-vue
v2.10.0
Published
Vue 3 composables and router integration for mikser-io — live documents, multilingual hrefs, asset resolution
Maintainers
Readme
mikser-io-sdk-vue
Wire a Vue 3 app to a mikser-io content backend in ~10 lines. Content stays as .md and .yml files on disk — diffable, grep-able, copy-anywhere. The composables below give you live updates over SSE, typed access to layout-shaped front-matter, multilingual URL resolution, transcoded asset URLs, and semantic search.
| What you get | Reads as |
|---|---|
| Live content | const { document } = useDocument(id) — re-renders as the file changes |
| Current document | useCurrentDocument() — the current-route document, one shared subscription (provide once at the root) |
| Live lists | const { documents } = useDocuments({ filter, sort, fields }) |
| Multilingual URLs | href('/about') → /en/about or /fr/a-propos per locale |
| Content by reference | meta('/menu').products — read a known document's fields by its logical $ref, no extra query |
| Hreflang + switchers | useAlternates({ route }) |
| Transcoded asset URLs | url(clip.meta.url) joins a served path from the catalog → <cms>/... |
| Semantic search | useSimilar(store, query) with built-in debounce + stale-discard |
| Live routes | useMikserRoutes(router, { mapRoute }) — augments your router, doesn't replace it |
| Build-time routes | generateMikserRoutes() for SSG manifests |
Augment, don't own. Your app stays yours. Mikser slots in alongside your own router, your own views, your own auth — nothing about the SDK insists on running the whole app.
One mental model across every rendering shape — pure SPA, hybrid SSG with a live editor, mikser-rendered HTML with Vue islands. Same composables, different mount. See examples/ for the three patterns side-by-side.
Typed at the seam. Pair with mikser-io-schemas to author Zod schemas alongside your content; useDocument<{ meta: MetaByLayout<'article'> }>(id) then carries the front-matter shape straight into your templates.
Built on mikser-io-sdk-api — same primitives (live(), list(), entities endpoints, the live-updates contract), wrapped as idiomatic Vue 3.
Zero own dependencies. Vue 3 + Vue Router 4 (optional) + mikser-io-sdk-api 3.x as peer deps.
Install
npm install mikser-io-sdk-vue mikser-io-sdk-api vue vue-routervue-router is an optional peer — install only if you use any of the router helpers.
Quick start
The app owns the router. Mikser slots catalog-driven routes in alongside your hand-coded ones via useMikserRoutes. The mapRoute callback dispatches each document to the view that matches its meta.component — different content types ship through different views (an article isn't a product isn't a landing page). Dispatch is on meta.component, not meta.layout; layout stays reserved for mikser's SSG render pipeline so the two never collide.
// main.js
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createClient } from 'mikser-io-sdk-api'
import { createMikserPlugin, useMikserRoutes } from 'mikser-io-sdk-vue'
import App from './App.vue'
// One client, one endpoint. `data.catalog` points at the static snapshot
// the data plugin writes (out/data/sitemap.json) — fast first paint
// from a CDN-cacheable file, then SSE deltas keep it current. No second
// API endpoint to configure.
const documents = createClient({ baseUrl: import.meta.env.VITE_MIKSER_URL })
.entities('public', { data: { catalog: 'sitemap', entities: 'page' } })
// One Vue view per content component. Each lazy-imports so it ships in
// its own chunk — articles ≠ products ≠ landing pages, code-wise.
const views = {
article: () => import('./views/ArticleView.vue'),
product: () => import('./views/ProductView.vue'),
landing: () => import('./views/LandingView.vue'),
page: () => import('./views/PageView.vue'), // fallback
}
// You construct the router. Hand-coded routes are first-class; mikser
// joins them later via useMikserRoutes.
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/articles', name: 'articles', component: () => import('./views/ArticleIndex.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('./views/NotFound.vue') },
],
})
// Plug mikser into the same router. seeded resolves when the initial
// snapshot (or list fallback) lands. Await it before mounting so
// first-paint navigation hits a registered route instead of falling
// through to NotFound.
const { seeded } = useMikserRoutes(router, {
mapRoute: document => ({
path: document.meta.route,
name: document.id,
component: views[document.meta.component] ?? views.page, // dispatch
props: route => ({ entityId: document.id, params: route.params }),
meta: { component: document.meta?.component, title: document.meta?.title },
}),
})
await seeded
createApp(App)
.use(createMikserPlugin({ client: documents }))
.use(router)
.mount('#app')The out/data/sitemap.json snapshot is produced by the data plugin's catalog config on the mikser side. The block fits in ~10 lines:
// mikser-content/mikser.config.js (server side)
{
plugins: ['documents', 'front-matter', 'plugin-schemas', 'data', 'api'],
data: {
catalog: {
// out/data/sitemap.json — every published, component-having
// document, projected to just the routing fields.
sitemap: {
query: e =>
e.type === 'document' &&
e.meta?.published &&
e.meta?.component,
pick: ['id', 'destination', 'meta.component', 'meta.route', 'meta.title'],
},
},
entities: {
// out/data/<entity.name>.page.json — one file per published
// document, with full content. useDocument(id) reads from
// these on first paint; live updates still flow over SSE.
page: {
query: e => e.type === 'document' && e.meta?.published,
pick: ['id', 'meta', 'content'],
},
},
},
api: {
endpoints: {
public: {
query: e => e.type === 'document' && e.meta?.published,
operations: ['list', 'subscribe'],
cache: true,
},
},
},
}The data plugin runs at finalize, writes one file per catalog entry under out/data/, served as a static asset by mikser's built-in handler. The pick projection is enforced server-side so the snapshot stays small. query lines up 1:1 with the live() filter the SDK opens after first paint — initial state matches what SSE will send.
See mikser-io-sdk-api → data.catalog for the client side, and examples/mikser-content/mikser.config.js for the full config in context.
The pattern is augment, don't own. You write the app you'd write anyway — your own routes, your own router setup, your own catch-all. Mikser slots its routes in alongside yours and keeps them current as content changes.
Two small things to add when you reproduce this pattern in your own app:
1.
build.target: 'es2022'invite.config.js— top-levelawaitis ES2022 syntax. Vite 5's default'modules'target is below the TLA cutoff andvite builderrors out otherwise. Every evergreen browser (Chrome 89+, Firefox 89+, Safari 15+, Edge 89+) runs es2022 natively. Alternatively, wrap the bootstrap in anasync function main() { … } main()IIFE — no build-target change needed.2. A loading state in
index.html—await seededblocks the mount for the ~100-500ms initial catalog round trip; without a shell the user sees a blank screen. Vue's.mount()replaces#app's inner content, so this disappears the moment routes are seeded:<body> <div id="app"><div class="loading">Loading…</div></div> <script type="module" src="/src/main.js"></script> </body>Both example apps ship with both pieces — see
examples/pure-spaandexamples/hybrid-ssg.
What entityId is
entityId is the mikser entity id — every document, file, and asset in the catalog has one. For documents it's the source file path under the working folder, e.g. /documents/en/articles/welcome.md. It's stable across content edits (only a rename changes it) and globally unique within a mikser instance.
The flow through the Quick Start above:
mapRoute receives a document → its document.id is '/documents/en/articles/welcome.md'
passes document.id as prop → ArticleView gets <ArticleView :entity-id="…welcome.md" />
useDocument(entityId) → opens a live subscription for { id: '…welcome.md' }
SDK filters by id → server returns just that one document
editor edits the file → SSE event flows back → component re-rendersThe entityId prop name follows mikser's vocabulary — documents, files, assets are all entities, and entityId is what the catalog calls their identifier. The SDK doesn't reserve the name; you can call the prop anything, but staying with entityId makes a Vue codebase recognisable to anyone who already knows mikser's terms.
Documents declare their component in front-matter — the router uses it to pick the view. (layout stays separate, reserved for mikser's SSG render pipeline.)
# documents/en/articles/welcome.md
---
component: article # ← drives the dispatch to ArticleView.vue
title: Welcome
author: Alice Park
date: 2026-05-01
route: /en/articles/welcome
published: true
---
# documents/en/products/desk-lamp.md
---
component: product # ← drives the dispatch to ProductView.vue
title: Desk Lamp
price: 149
image: /assets/desk-lamp.jpg
sku: LMP-001
route: /en/products/desk-lamp
in_stock: true
---
# documents/en/campaigns/spring-sale.md
---
component: landing # ← drives the dispatch to LandingView.vue
title: Spring Sale
hero: /assets/spring-hero.jpg
cta: { label: 'Shop now', href: '/products' }
route: /en/campaigns/spring-sale
---Three documents, three different components → three structurally different views. None of them share template code; they all share the same data primitive (useDocument).
ArticleView.vue — article shape. Headline, byline, date, body.
<!-- views/ArticleView.vue -->
<script setup>
import { useDocument } from 'mikser-io-sdk-vue'
const props = defineProps({ entityId: String })
const { document: article, loading } = useDocument(() => props.entityId)
</script>
<template>
<article v-if="article" class="article">
<header>
<h1>{{ article.meta?.title }}</h1>
<p class="byline">
By <strong>{{ article.meta?.author }}</strong>
· <time>{{ article.meta?.date }}</time>
</p>
</header>
<div class="article-body" v-html="article.content" />
<footer>
<router-link to="/articles">← All articles</router-link>
</footer>
</article>
<p v-else-if="loading">Loading…</p>
</template>ProductView.vue — product shape. Image gallery, price, stock state, add-to-cart, description. Nothing in common with an article visually.
<!-- views/ProductView.vue -->
<script setup>
import { useDocument } from 'mikser-io-sdk-vue'
const props = defineProps({ entityId: String })
const { document: product } = useDocument(() => props.entityId)
function addToCart() { /* hand off to the cart store */ }
</script>
<template>
<section v-if="product" class="product">
<figure class="product-image">
<img :src="product.meta?.image" :alt="product.meta?.title" />
</figure>
<div class="product-info">
<h1>{{ product.meta?.title }}</h1>
<p class="sku">SKU {{ product.meta?.sku }}</p>
<p class="price">€{{ product.meta?.price }}</p>
<p v-if="product.meta?.in_stock" class="stock in">In stock</p>
<p v-else class="stock out">Out of stock</p>
<button :disabled="!product.meta?.in_stock" @click="addToCart">
Add to cart
</button>
<div class="product-description" v-html="product.content" />
</div>
</section>
</template>LandingView.vue — landing-page shape. Full-bleed hero, CTA, body sections. Again, nothing in common with the other two structurally.
<!-- views/LandingView.vue -->
<script setup>
import { useDocument } from 'mikser-io-sdk-vue'
const props = defineProps({ entityId: String })
const { document: page } = useDocument(() => props.entityId)
</script>
<template>
<div v-if="page" class="landing">
<section class="hero" :style="{ backgroundImage: `url(${page.meta?.hero})` }">
<h1>{{ page.meta?.title }}</h1>
<a v-if="page.meta?.cta" :href="page.meta.cta.href" class="cta-button">
{{ page.meta.cta.label }}
</a>
</section>
<section class="content" v-html="page.content" />
</div>
</template>ArticleIndex.vue — a collection listing using useDocuments. Different shape entirely (a list, not a single document); same live-update contract underneath.
<!-- views/ArticleIndex.vue -->
<script setup>
import { useDocuments } from 'mikser-io-sdk-vue'
const { documents: articles } = useDocuments({
filter: { 'meta.component': 'article', 'meta.published': true },
sort: { 'meta.date': -1 },
fields: ['id', 'meta.title', 'meta.date', 'meta.author', 'meta.summary', 'meta.route'],
limit: 20,
})
</script>
<template>
<main>
<h1>Articles</h1>
<ul class="article-list">
<li v-for="a in articles" :key="a.id">
<router-link :to="a.meta.route">
<h2>{{ a.meta.title }}</h2>
<p class="byline">{{ a.meta.author }} · {{ a.meta.date }}</p>
<p>{{ a.meta.summary }}</p>
</router-link>
</li>
</ul>
</main>
</template>The five compose under live updates:
- Editor publishes a new article in Decap →
ArticleIndexreceives thecreateevent and the new card appears at the top of the list, no refresh. - Click it → router dispatches via
meta.component: 'article'→ArticleViewmounts → itsuseDocumentsubscription opens → editor edits the body → the article re-renders in place. - Browse to
/en/products/desk-lamp→ router dispatches viameta.component: 'product'→ProductViewmounts with image, price, CTA — visibly nothing like an article. - Editor toggles
in_stock: falsein the front-matter →ProductViewre-renders with the button disabled and the stock label flipped. Same composable. Different shape. Same live update.
useDocument is the data primitive — every content view in the app uses it. The view's job is just to render a particular shape on top of that data. The router decides which shape via meta.component. The dispatch is what makes this scale to dozens of content types without growing one giant DocumentPage component.
That's the whole story. Everything below is detail.
Scenarios — picking the right shape for your project
Three common shapes. Each makes a different trade between SEO, build complexity, and how much Vue does. Pick before you start; mixing them mid-project is painful.
📦 Runnable starter projects
Each scenario ships as a complete starter under
examples/— Vite config,package.json, full source tree, its own README explaining how to run it. Clone and modify rather than translate the snippets below into project structure.| Folder | What's in it | |---|---| |
examples/mikser-content| The shared content server — a standalone mikser project that supplies the catalog to the three Vue apps below. Start it first. | |examples/pure-spa(scenario A) | Vite + Vue +useMikserRoutesagainst your own router | |examples/dynamic-spa(scenario D) | Same shape aspure-spabut with a catch-all +useDocumentByRoute| |examples/hybrid-ssg(scenario B) | Two Vite configs (public + editor), sharedroute-mapping.js| |examples/islands(scenario C) | Multi-entry Vite build, three Vue islands mounting onto mikser-rendered HTML |
A) Pure SPA — runtime everything, live everywhere
When: Editor UIs, admin dashboards, internal apps. SEO doesn't matter. You want the fastest dev loop and the lowest build complexity.
How it works: No build-time route enumeration. The app constructs its own router, then wires useMikserRoutes against it to slot catalog routes in alongside the hand-coded ones. Editing a document → SSE event → addRoute / removeRoute → UI updates. No rebuild ever.
// main.js
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createClient } from 'mikser-io-sdk-api'
import { createMikserPlugin, useMikserRoutes } from 'mikser-io-sdk-vue'
import App from './App.vue'
const documents = createClient({ baseUrl: import.meta.env.VITE_MIKSER_URL })
.entities('public', { data: { catalog: 'sitemap', entities: 'page' } })
const router = createRouter({
history: createWebHistory(),
routes: [
// Your hand-coded routes
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('./views/NotFound.vue') },
],
})
// seeded resolves when the initial snapshot (or list fallback) lands.
const { seeded } = useMikserRoutes(router, {
mapRoute: document => ({
path: document.meta.route,
name: document.id,
component: () => import('./views/DocumentPage.vue'),
props: route => ({ entityId: document.id, params: route.params }),
meta: { component: document.meta?.component },
}),
})
await seeded
createApp(App)
.use(createMikserPlugin({ client: documents }))
.use(router)
.mount('#app')Trade-offs: Fastest to set up. Worst for SEO (the public-facing HTML is empty until JS loads). Initial boot pays a listAll() round trip (~100-500ms for typical sites).
📦 Full starter project:
examples/pure-spa— clone,npm install, setVITE_MIKSER_URL,npm run dev.
B) Hybrid — SSG for public, SPA-with-live for editor
When: Marketing sites, blogs, documentation, any content site that needs SEO. The typical agency project.
The idea: Two builds from the same content. The public deploy is pre-rendered HTML (one file per route, optimised for crawlers + CDN). The editor / admin app is the SPA from scenario A, talking to the same mikser server. Both share the same mapRoute function, so they agree on what a route is.
Build script — runs in CI before the production build:
// build/generate-routes.mjs
import { writeFile } from 'node:fs/promises'
import { createClient } from 'mikser-io-sdk-api'
import { generateMikserRoutes } from 'mikser-io-sdk-vue'
import { mapRoute } from '../src/route-mapping.js' // shared
const documents = createClient({ baseUrl: process.env.MIKSER_URL }).entities('public')
const routes = await generateMikserRoutes({ client: documents, mapRoute })
await writeFile('./src/generated/routes.json',
JSON.stringify(routes.map(r => ({ ...r, component: undefined })), null, 2))
// ^ strip the component (it's a function — not JSON-serializable). The
// runtime router rehydrates it via mapRoute at boot.Production router — reads the manifest, no list() call needed:
// src/router.js (production build)
import { createRouter, createWebHistory } from 'vue-router'
import generatedRoutes from './generated/routes.json'
import DocumentPage from './views/DocumentPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
...staticRoutes,
...generatedRoutes.map(r => ({ ...r, component: DocumentPage })),
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
],
})Then run Vite SSG or vite-plugin-ssr to pre-render each route's HTML. Each route's page calls useDocument() during SSR to fetch the content — produces a fully-rendered HTML file.
Editor app — separate entry point, uses scenario A (useMikserRoutes + live composables). Mounted under /admin/* or on a different domain. Stays live always.
project/
src/
public/main.js ← SSG entry, uses generated routes
editor/main.js ← SPA entry, uses createRouter + useMikserRoutes (live)
build/
generate-routes.mjs ← run before vite buildTrade-offs: Two entry points, two build steps, slightly more wiring. In exchange: SEO-correct, CDN-friendly public deploy + live editor preview from the same content source.
📦 Full starter project:
examples/hybrid-ssg— the load-bearing file issrc/route-mapping.js, shared between three consumers (build script, public router, editor router).
C) Mikser-rendered HTML + Vue islands
When: Content-heavy sites where most pages are pure content (mikser renders them perfectly) but a few features need interactivity (search box, contact form, filters, live counts).
The idea: Mikser is responsible for the HTML. Vue is just an enhancement layer that mounts onto specific DOM nodes the server-rendered HTML emits. No vue-router involved — the URLs are real URLs served as static files.
Public site: mikser build produces out/. Deploy out/ as static. The HTML includes a mount point for the Vue island:
<!-- documents/en/search.md → rendered via layouts/page.html.hbs -->
<article>
<h1>{{meta.title}}</h1>
<div id="search-island" data-endpoint="public"></div>
</article>Vue island bundle — separate Vite build, mounted on demand:
// src/islands/search.js
import { createApp } from 'vue'
import { createClient } from 'mikser-io-sdk-api'
import { createMikserPlugin } from 'mikser-io-sdk-vue'
import SearchBox from './SearchBox.vue'
const el = document.getElementById('search-island')
if (el) {
const documents = createClient({ baseUrl: '/' }) // same-origin
.entities(el.dataset.endpoint)
createApp(SearchBox)
.use(createMikserPlugin({ client: documents }))
.mount(el)
}SearchBox.vue — uses useDocuments() to query mikser for results:
<script setup>
import { ref, computed } from 'vue'
import { useDocuments } from 'mikser-io-sdk-vue'
const q = ref('')
const query = computed(() => ({
filter: q.value ? { 'meta.title': { $regex: q.value } } : { id: '__none__' },
fields: ['id', 'meta.title'],
limit: 10,
}))
const { documents } = useDocuments(query)
</script>
<template>
<input v-model="q" placeholder="Search…" />
<ul><li v-for="document in documents" :key="document.id">{{ document.meta.title }}</li></ul>
</template>Trade-offs: Best performance (static HTML + small Vue bundle, lazy-loaded). Simplest deployment (just files). But Vue doesn't own routing — the URL structure is mikser's responsibility.
📦 Full starter project:
examples/islands— three islands (search, booking, cart-counter) and a simulated mikser-rendered HTML page showing where they mount.
D) Dynamic routes — for catalogs too big to enumerate
When: A content catalog past the ~5k–10k route mark where loading every route into a snapshot at boot stops making sense — large blogs, e-commerce catalogs, knowledge bases, document archives.
The idea: Stop enumerating routes. Install one catch-all pattern in the router; resolve the document at navigation time via useDocumentByRoute(path). The api plugin's per-query disk cache turns each unique route into an on-demand static file: the first user hits mikser, subsequent users get the cached response served by the reverse proxy. Effectively per-route ISR with no extra config.
// main.js — note no data.catalog, no useMikserRoutes
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createClient } from 'mikser-io-sdk-api'
import { createMikserPlugin } from 'mikser-io-sdk-vue'
import App from './App.vue'
const documents = createClient({ baseUrl: import.meta.env.VITE_MIKSER_URL })
.entities('public')
const router = createRouter({
history: createWebHistory(),
routes: [
// Hand-coded routes first
{ path: '/', name: 'home', component: () => import('./views/Home.vue') },
{ path: '/search', name: 'search', component: () => import('./views/Search.vue') },
// Single catch-all for everything content-backed
{ path: '/:pathMatch(.*)*', name: 'doc', component: () => import('./views/DocumentResolver.vue') },
],
})
createApp(App)
.use(createMikserPlugin({ client: documents }))
.use(router)
.mount('#app')<!-- views/DocumentResolver.vue -->
<script setup>
import { useRoute } from 'vue-router'
import { useDocumentByRoute } from 'mikser-io-sdk-vue'
import ArticleView from './ArticleView.vue'
import ProductView from './ProductView.vue'
import PageView from './PageView.vue'
import NotFound from './NotFound.vue'
const route = useRoute()
const { document, loading } = useDocumentByRoute(() => route.path)
const views = { article: ArticleView, product: ProductView, page: PageView }
</script>
<template>
<p v-if="loading">Loading…</p>
<component v-else-if="document" :is="views[document.meta?.component] ?? PageView" :entity-id="document.id" />
<NotFound v-else />
</template>How the caching works. useDocumentByRoute issues a request like GET /api/public/entities?meta.route=/en/about&meta.published=true&limit=1&cache=4f3a2c1d8e9b6f7a. The SDK appends the cache=<sha256-prefix> param automatically. With cache: true on the public endpoint mikser writes that response to out/api/public/entities/4f3a2c1d8e9b6f7a.json (named by the same hash the SDK sent). The standard nginx failover config — try_files /api/public/entities/$arg_cache.json @proxy — serves the file directly on subsequent requests via the client-provided hint, no Lua needed. See mikser-io's caching docs for the full config across nginx / Caddy / Cloudflare / Apache.
So the architecture is:
- First visitor to a route: SDK → mikser → response served + written to disk
- Every subsequent visitor: SDK → proxy serves the cached file (mikser idle)
- Catalog change: entire cache directory cleared, re-warms on demand
Trade-offs: First paint on a cold route pays one API roundtrip — slower than the snapshot model's pre-loaded routes, faster than a registered SPA's full-tree boot once the cache is warm. Doesn't scale down well to small catalogs (you're paying the catch-all-resolver tax for routes you could have enumerated for free) but scales up beautifully — works the same at 10k routes as at 10M.
When to pick D over A: roughly when data.catalog.sitemap projection would emit more than ~1–2 MB. Past that the snapshot is dragging first paint down more than the resolver does.
📦 Full starter project:
examples/dynamic-spa— same shape aspure-spabut with the catch-all pattern wired up. Compare them side-by-side to see the diff.
Picking between them
| Question | A (SPA) | B (Hybrid SSG) | C (Islands) | D (Dynamic SPA) | |---|---|---|---|---| | Do you need SEO? | No | Yes | Yes | No | | Is most of the page interactive? | Yes | Maybe | No | Yes | | Is content mostly static? | No | Yes | Yes | No | | Editor + admin in same app? | Yes | Editor is the SPA half | Separate admin app | Yes | | Build complexity tolerance | Low | Medium | Low | Low | | Mikser plugins (post-pdf, post-mjml) used? | No | Maybe | Yes | No | | Catalog size | < 5k routes | any | any | > 5k routes |
Rule of thumb for an agency client site: start with C (islands) for the public site if the content is mostly static, B (hybrid SSG) if there's significant interactivity, A (pure SPA) only for the admin app. Pick D when A would otherwise be your choice but the catalog is past the snapshot ceiling. A/D and B/C often coexist in the same project — the admin is always SPA-shaped; the public face is the project-by-project decision.
When to use which composable
| Composable | Best for | Avoid when |
|---|---|---|
| client.list() directly | Build-time, SSR (no live updates needed) | Component that needs to react to changes |
| useDocument() / useDocuments() | Components in any scenario | Plain Node scripts (use the SDK directly) |
| useDocumentByRoute() | Scenario D catch-all view — resolve the current path to a document | Scenarios A/B (you have the entity id; use useDocument) |
| live() underneath both | Always — they wrap it | — |
| useMikserRoutes | Scenarios A or B (editor app) — your router, mikser slots in | Scenario C (no router) |
| generateMikserRoutes | Scenario B (build step) | Scenarios A or C |
| useHref + useAsset | Any scenario with Vue components | Mikser-rendered HTML (use the render-href plugin server-side instead) |
useDocument / useDocuments are safe to use in SSR — when there's no DOM, they fetch the initial snapshot and the live() subscription is a no-op (the response stream closes when the server finishes rendering). On the client, hydration re-subscribes and live updates resume.
Surface
Each export does one job. Mikser augments your app — you own the router; the SDK helpers slot in alongside.
| Export | What it does |
| ----------------------- | --------------------------------------------------------------------------- |
| createMikserPlugin | Vue plugin — provides the entities client app-wide |
| useMikserClient | Injection accessor for the raw client (rare; the other composables use it) |
| useDocument | Reactive single-document composable with live updates |
| useDocuments | Reactive list composable with live updates |
| provideCurrentDocument / useCurrentDocument | One shared current-route document for the subtree — provide once, read anywhere. References resolve by default ($ wildcard); expand: [] opts out |
| useMikserRoutes | Live diff (addRoute / removeRoute) against your existing router. Returns { dispose, seeded }. |
| generateMikserRoutes | Build-time helper — outputs a routes array for static-build pipelines |
| provideHrefIndex / useHref | Multilingual URL abstraction — resolve /about → /en/about per locale |
| useAlternates | Alternate-language URLs for the current route — language switchers + SEO hreflang |
| useAsset | Format-neutral asset helpers — url(ref) joins a deployed served path to the client base; asset(ref) looks up a managed entity (needs provideAssetIndex) |
| createReactiveCache | Load-once, expand-capable reactive content cache — readable from non-component code (Pinia stores, plain modules), not just templates |
| createMikserVectorPlugin / useMikserVectorClient | Bridges mikser-io-sdk-vector into Vue's provide/inject |
| useSimilar | Live semantic search with debounce + stale-result discard |
Composables — document data
useDocument(id, options?)
const { document, loading, error, refresh } = useDocument(() => props.entityId)idcan be a string, a Ref, or a getter. When it changes, the subscription re-subscribes for the new id.documentis null while loading or when the document doesn't exist.- Live-updates via
client.live({ id })under the hood — when the document changes server-side, the ref updates without manual refetch. - Disposes the subscription on
onUnmounted.
useDocument<T>(...) is generic — pass the entity shape and document is typed accordingly. The recommended source for T is the entities.d.ts emitted by mikser-io-schemas, which generates one XxxMeta alias per layout from Zod schemas in the mikser project:
import type { MetaByLayout } from '../mikser-content/entities'
const { document } = useDocument<{ meta: MetaByLayout<'article'> }>(id)
// document.value.meta.title / .author / .summary are all typedWithout mikser-io-schemas, you can still pass a hand-written interface — useDocument<MyArticle>(id) — the SDK doesn't care where T comes from.
useDocuments(query, options?)
const { documents, loading, error, refresh } = useDocuments({
filter: { type: 'document', 'meta.published': true },
sort: { 'meta.date': -1 },
fields: ['id', 'meta.title', 'meta.date', 'meta.summary'],
limit: 20,
})querycan be a static object, a Ref, or a getter. Deep-watched — when the filter / sort / fields / limit change, the subscription re-evaluates.documentsis reactive — pushes to<v-for>update in place as the underlying content changes.
The current-route document
Most routes render one document end-to-end — the page <head>, the body copy, the media. provideCurrentDocument loads that document once for the current route and shares it with the whole subtree; every child reads it with useCurrentDocument(). No prop-drilling, no global, one subscription.
// App.vue — wraps every route
import { provideCurrentDocument } from 'mikser-io-sdk-vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const { document, loading } = provideCurrentDocument({
route: () => route.path, // a path string, Ref, or getter — the SDK reads it, never imports your router
expand: [], // ← the one decision that matters; see below
})
useHead(/* derive from document.value?.meta.head */)// any descendant
const { document } = useCurrentDocument()Who owns the references
A document carries $-keyed references — a $video, a $hero, a list of $related. Resolving them is expand's job, and the default is the $ wildcard: call provideCurrentDocument with no expand and the document arrives with every reference resolved. That's the right default — a document should come whole.
But the root provide in App.vue is the wrong place to do that resolving. It serves two kinds of consumer, and it's the ancestor of every route:
- the page
<head>and components that read plain fields (meta.title,meta.prices) never touch a reference; - a uniform expand at the root would make the login form resolve the product videos it never renders.
So the root provide opts out — expand: [] — and each view that actually renders references provides its own current-route document. That provide shadows the root for its subtree and takes the default ['$']:
// views/Home.vue — renders the presentation + FAQ videos
import { provideCurrentDocument } from 'mikser-io-sdk-vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const { document } = provideCurrentDocument({ route: () => route.path }) // default expand: ['$']The principle: a document doesn't know its consumers, so the consumer declares what to resolve. Not the document (different views of the same doc want different shapes), not the route table (same problem), not one app-level provide (it resolves for routes that render nothing). The view that renders the references is the one that knows it needs them — so it owns the expand. ['$'] is the default because resolved is the common case; [] is the opt-out for the plain-field path.
Rejected on the way here: a single central expand (couples unrelated routes; the head path pays for refs it never reads) and a document- or route-declared expand (the document would have to know who consumes it — it doesn't). Consumer-owned is the one that holds up.
The route-binding wrapper
provideCurrentDocument is router-agnostic on purpose — it reads a path, it doesn't import vue-router. Bind it once in a small app composable, and every view calls a no-arg helper:
// composables/document.js
import { useRoute } from 'vue-router'
import { provideCurrentDocument } from 'mikser-io-sdk-vue'
import { client } from '../lib/mikser'
// A view owns its document load; references resolve by default ($ wildcard).
// Pass [] to opt out, or a path list to narrow.
export function provideRouteDocument(expand) {
const route = useRoute()
return provideCurrentDocument({ client, route: () => route.path, expand })
}// App.vue → provideRouteDocument([]) // neutral: head + plain fields
// views/Home.vue (renders refs) → provideRouteDocument() // default ['$']: refs resolved
// a child component → useCurrentDocument() // reads whichever provide is nearestOne neutral provide at the root, a per-view override wherever references are rendered, and the consumer — never the document — choosing the resolution. That's the whole architecture.
Router integration
Three helpers, all composition-oriented — your app owns the router, mikser slots in alongside.
useMikserRoutes(router, options)
The common case. Wires a live diff loop against an existing vue-router instance: addRoute for catalog entries that appear, removeRoute for entries that disappear. Returns { dispose, seeded }.
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createClient } from 'mikser-io-sdk-api'
import { createMikserPlugin, useMikserRoutes } from 'mikser-io-sdk-vue'
import App from './App.vue'
// One client. `data.catalog` pulls the narrow snapshot the data plugin
// writes — fast first paint, CDN-cacheable — then live SSE keeps it
// current.
const documents = createClient({ baseUrl: import.meta.env.VITE_MIKSER_URL })
.entities('public', { data: { catalog: 'sitemap', entities: 'page' } })
// Your router. Your routes. Your guards.
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('./views/Login.vue') },
{ path: '/dashboard', name: 'dashboard', component: () => import('./views/Dashboard.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('./views/NotFound.vue') },
],
})
// Plug mikser into the same router. Await seeded before mount.
const { seeded } = useMikserRoutes(router, {
mapRoute: document => ({
path: document.meta.route,
name: document.id, // required — used as diff key
component: () => import('./views/DocumentPage.vue'),
props: { entityId: document.id },
}),
})
await seeded
createApp(App)
.use(createMikserPlugin({ client: documents }))
.use(router)
.mount('#app')mapRoutemust return routes with aname— addRoute / removeRoute use it as the diff key. Routes without a name are silently skipped.- Avoid name collisions between your hand-coded routes and catalog-driven ones;
addRouteoverwrites on duplicate names. seededresolves the first time the initial catalog list has landed. Await it before mounting so first-paint navigation hits a registered route instead of falling through to your NotFound and re-navigating once routes appear.dispose()stops the live subscription. Idempotent. Also runs automatically on the surrounding effect scope's teardown.
generateMikserRoutes(options)
The build-time version. One-shot list, returns plain route definitions. Use this in a Vite SSG hook or any static-build pipeline that needs to enumerate routes upfront.
// build/generate-routes.mjs
import { writeFile } from 'node:fs/promises'
import { createClient } from 'mikser-io-sdk-api'
import { generateMikserRoutes } from 'mikser-io-sdk-vue'
const documents = createClient({ baseUrl: process.env.MIKSER_URL }).entities('public')
const routes = await generateMikserRoutes({
client: documents,
mapRoute: document => ({ path: document.meta.route, name: document.id, /* … */ }),
})
await writeFile('./src/generated/routes.json', JSON.stringify(routes, null, 2))Same mapRoute function can power both build-time and runtime — one source of truth for what a content route looks like.
Multilingual href() — provideHrefIndex + useHref
This is the most load-bearing pattern in the library. If a project has more than one language — and most agency projects do — this is what keeps the codebase from drowning in conditional URL construction.
The pattern, and why it matters
A multilingual content site has two distinct things:
- Logical references —
/about,/contact,/blog/welcome— the abstract idea of a page, independent of language. Stable across translations. - Deployed URLs —
/en/aboutvs/fr/a-proposvs/de/uber-uns— the actual paths the server emits, with locale-specific slugs.
Most multilingual setups conflate these. You write /en/about in your template, then hard-code conditionals to swap it. Or you push the language into a vue-router prefix and lose the ability to translate slugs. Or you build a custom routing layer in i18n config that diverges from the actual file structure.
useHref() makes the separation explicit: write the logical reference; the resolver picks the deployed URL based on the current locale and the catalog of published documents. The content author defines the URL for each translation in front-matter; the application code never sees the slug variation.
What this gets you for free:
- Translated slugs (
/uber-unsin DE,/a-proposin FR,/aboutin EN) without per-language code paths - Reactive locale switching — change
locale.value, every:toupdates - Broken-link visibility —
href('/typo')returns/typounchanged, so QA sees it in the DOM instead of hitting silentundefinedfailures - Editor-friendly authoring — translators control their own URLs from front-matter, not from code
It's not flashy. It just removes an entire class of bug.
Front-matter convention
Three fields on each translatable document:
| Field | Purpose | Example |
|---|---|---|
| href | Logical reference — the same across all translations | /about |
| lang | Which language this translation represents | en, fr, de, bg |
| route | The deployed URL — what users actually navigate to | /en/about, /fr/a-propos |
# documents/en/about.md
---
title: About us
href: /about # logical reference
lang: en # which language
route: /en/about # deployed URL
---
# documents/fr/a-propos.md
---
title: À propos
href: /about # same logical ref — this is the FR translation of "about"
lang: fr
route: /fr/a-propos # localised slug — the URL the browser navigates to
---
# documents/de/uber-uns.md
---
title: Über uns
href: /about # same logical ref
lang: de
route: /de/uber-uns
---Three documents, one logical /about, three localised slugs. provideHrefIndex builds the lookup { '/about': { en: '/en/about', fr: '/fr/a-propos', de: '/de/uber-uns' } } from the catalog and keeps it live as content changes.
Documents without meta.lang (e.g., single-language pages like /legal/terms) go in a 'default' bucket and are resolvable regardless of the requested locale.
Setup
Once per app, at the root or in a top-level layout component:
<!-- App.vue -->
<script setup>
import { provideHrefIndex } from 'mikser-io-sdk-vue'
provideHrefIndex() // uses the injected client
</script>
<template>
<router-view />
</template>The index is reactive — it stays in sync with the catalog via client.live(). Add a new translation in Decap, the URL becomes resolvable within milliseconds.
Basic usage
<script setup>
import { useHref } from 'mikser-io-sdk-vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n() // your existing i18n state
const { href } = useHref(locale) // reactive — re-resolves when locale changes
</script>
<template>
<nav>
<router-link :to="href('/')">Home</router-link>
<router-link :to="href('/about')">About</router-link>
<router-link :to="href('/contact')">Contact</router-link>
<!-- Explicit override — show this link in French regardless of current locale -->
<a :href="href('/about', 'fr')">Voir en français</a>
</nav>
</template>Content by reference — meta(ref) / doc(ref)
The href index already loads every published document keyed by its logical meta.href, so it doubles as a content index. useHref() exposes meta(ref) (and doc(ref)) alongside href(ref) — read a known document's fields by its reference without a second query. The natural fit for shared content that's referenced by a stable logical name rather than reached by route: navigation menus, price lists, translation dictionaries, a site-wide settings document.
<script setup>
import { useHref } from 'mikser-io-sdk-vue'
const { href, meta } = useHref()
</script>
<template>
<!-- href(ref) → URL ; meta(ref) → that document's content. Both reactive. -->
<ul class="menu">
<li v-for="item in meta('/menu')?.products" :key="item.id">
<router-link :to="href(item.ref)">{{ item.name }}</router-link>
</li>
</ul>
</template>meta(ref) returns null for an unknown ref (vs. href(ref) which echoes the ref back so broken links stay visible). It reads from the same live subscription as href(), so it re-evaluates when the referenced document changes — and adds no extra request. Pair it with mikser-io-schemas to type the returned meta per layout.
When the user toggles locale.value, every :to binding re-evaluates. No watch, no manual update.
Common patterns
Language switcher — link to the current page in every other language
The killer use case. Take the current route, find its logical href, then resolve to every other language. The reverse lookup + multi-language fan-out is built into the SDK as useAlternates() — no manual index traversal needed.
<!-- components/LanguageSwitcher.vue -->
<script setup>
import { useRoute } from 'vue-router'
import { useAlternates } from 'mikser-io-sdk-vue'
import { useI18n } from 'vue-i18n'
const route = useRoute()
const { locale, availableLocales } = useI18n()
// Explicit language list — show every locale the app supports, even
// for pages where a translation doesn't exist yet (the SDK falls back
// via href()'s resolution chain). Right shape for a switcher.
const { alternates } = useAlternates({
route: () => route.path,
languages: availableLocales,
})
</script>
<template>
<ul class="lang-switcher">
<li v-for="alt in alternates" :key="alt.lang">
<router-link :to="alt.url">{{ alt.lang.toUpperCase() }}</router-link>
</li>
</ul>
</template>The alternates ref is reactive on route.path — navigate to a different page and the list re-resolves automatically. It already excludes the current page's own language (that lives on current from the same composable if you want to include it).
Now a visitor on /en/about clicking "FR" lands on /fr/a-propos — the translation, not just a language switcher that takes them back to the home page (which is what most i18n routing libraries do by default).
Breadcrumbs across the section
Same pattern. Logical refs everywhere; the resolver localises:
<script setup>
import { useHref } from 'mikser-io-sdk-vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const { href } = useHref(locale)
const crumbs = [
{ ref: '/', label: 'Home' },
{ ref: '/products', label: 'Products' },
{ ref: '/products/sofa-line', label: 'Sofa Line' },
]
</script>
<template>
<nav class="crumbs">
<router-link v-for="c in crumbs" :key="c.ref" :to="href(c.ref)">
{{ c.label }}
</router-link>
</nav>
</template>The labels would normally come from i18n too ($t('crumbs.products')). The point is the URLs — c.ref stays stable; the deployed path resolves per locale.
Path parameters
For collections (blog posts, products, categories) where each item has its own translated slug, store the resolved route directly on the item and skip href() for that link — href() is for the static references in your code, not for runtime-resolved item URLs.
<script setup>
import { useDocuments } from 'mikser-io-sdk-vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
// Query for posts in the current language.
const { documents: posts } = useDocuments(() => ({
filter: { 'meta.collection': 'posts', 'meta.lang': locale.value },
sort: { 'meta.date': -1 },
fields: ['id', 'meta.title', 'meta.route'],
limit: 20,
}))
</script>
<template>
<ul>
<li v-for="post in posts" :key="post.id">
<router-link :to="post.meta.route">{{ post.meta.title }}</router-link>
</li>
</ul>
</template>Collection items use their own meta.route directly. Use href() for the navigation chrome (Home, About, Contact, sections) and for cross-references between specific documents.
Programmatic navigation
href() returns a string — it composes with router.push() and any other place a URL is needed:
import { useRouter } from 'vue-router'
import { useHref } from 'mikser-io-sdk-vue'
import { useI18n } from 'vue-i18n'
const router = useRouter()
const { locale } = useI18n()
const { href } = useHref(locale)
async function onSubmit() {
await saveForm()
router.push(href('/thank-you'))
}Resolution behavior in detail
The fallback chain: requested lang → 'default' bucket → any available language → the input reference unchanged.
| Call | Index state | Result | Why |
|---|---|---|---|
| href('/about', 'en') | { '/about': { en: '/en/about', fr: '/fr/a-propos' } } | /en/about | Exact match |
| href('/about', 'de') | Same (no DE document exists) | /en/about (or the first available) | Falls back to any available language so the link still works |
| href('/contact', 'en') | { '/contact': { default: '/contact' } } | /contact | Single-language documents live in default; matches across any locale request |
| href('/contact', 'en') | { '/contact': { default: '/contact', en: '/en/contact' } } | /en/contact | Locale-specific wins over default when present |
| href('/typo') | Not in index | /typo | Unresolved — pass-through. Broken links stay visible in the DOM. QA / Lighthouse / link-checkers spot them. |
| href('/about') (no lang passed) | — | Uses defaultLangRef from useHref(localeRef) | The composable's default lang |
The pass-through behaviour for unresolved refs is deliberate. Silently returning undefined or '/' would hide link rot from sight. Passing the input through means broken links render as broken anchors (<a href="/typo">…</a>) that are immediately greppable in the deployed site, surface in link-check tooling, and crash navigation tests instead of silently no-oping.
SEO: hreflang tags
For SEO-critical multilingual sites you'll want <link rel="alternate" hreflang="…"> in the document head for every language version of the current page. The href index is exactly the data structure for that:
<!-- AppHead.vue (use with Vue Meta / Unhead / Vue 3's built-in head support) -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useHead } from '@unhead/vue'
import { useAlternates } from 'mikser-io-sdk-vue'
const route = useRoute()
// No `languages` arg — return only translations that actually exist.
// Right shape for hreflang: don't advertise pages we don't have.
const { alternates } = useAlternates({ route: () => route.path })
useHead({
link: computed(() => alternates.value.map(({ lang, url }) => ({
rel: 'alternate',
hreflang: lang,
href: url,
}))),
})
</script>Same composable, different shape of the answer — explicit languages for switchers (fall back when a translation doesn't exist), no languages for SEO (only advertise translations that exist). One catalog, two consumers, no manual index traversal.
How the index gets built
Under the hood provideHrefIndex uses client.live({ 'meta.href': { $exists: true } }) to subscribe to every document with an href field, and recomputes the index whenever the catalog changes. The same SSE stream that powers useDocument / useDocuments keeps the href map fresh.
This means:
- Adding a new translation in Decap → the new locale becomes resolvable in the SPA within ~1 second, no rebuild
- Renaming a route in front-matter → all
useHref()consumers update on the next render - Deleting a document → its locale entry drops out of the index;
href()falls back through the chain
For SSG (scenario B from above), serialise the index at build time and hydrate from it on boot — same shape, just static instead of live:
// build/generate-href-index.mjs
import { writeFile } from 'node:fs/promises'
import { createClient } from 'mikser-io-sdk-api'
const documents = createClient({ baseUrl: process.env.MIKSER_URL }).entities('public')
const { items } = await documents.list({
filter: { 'meta.href': { $exists: true } },
fields: ['id', 'meta'],
limit: 10_000,
})
const index = {}
for (const d of items) {
const ref = d.meta?.href, lang = d.meta?.lang ?? 'default'
const url = d.meta?.route ?? d.meta?.destination ?? ref
if (!ref) continue
if (!index[ref]) index[ref] = {}
index[ref][lang] = url
}
await writeFile('./src/generated/href-index.json', JSON.stringify(index, null, 2))Then ship a tiny replacement for provideHrefIndex that reads the JSON and provides it statically. The useHref() consumer code doesn't change.
Server-side correspondence
The Vue-side href() mirrors what mikser's own render-href plugin does on the server during template rendering. The naming, the front-matter convention, and the fallback semantics are intentionally identical — same meta.href / meta.lang / meta.route fields, same logical-ref-to-deployed-URL resolution.
Practical consequence: if you have a hybrid setup (mikser renders some pages statically, Vue renders others), links between them resolve consistently. A mikser-rendered page that points at /about and a Vue-rendered page that calls href('/about') produce the same URL for the same locale.
Asset references — useAsset
mikser's assets() plugin is a preset transcoder (video, image, pdf, audio…), not an image pipeline — so the SDK's asset helpers are format-neutral. useAsset() returns { url, asset, index }.
url(ref) — the primary helper
Joins a deployed, base-relative served path to the client base. The path is the one mikser's engine stamped into entity meta — meta.url for a served file, meta.presets.<name> for a transcoded derivative — and surfaced into the catalog via expand. One rule covers files and derivatives alike. The base is bound automatically from the installed client; nothing is constructed client-side, so there's no /assets/<preset>/<source> assembly to keep in sync with the plugin.
watchAssetFallbacks() is also exported — call it in dev to catch served URLs that resolve to the SPA fallback (a missing base prefix or an unexpanded ref shows up as an <img>/<video> that fetched HTML instead of media).
watchUnmatchedRoutes(router) is the routing sibling — call it in dev to catch the other silent class: a navigation that matches no route renders an empty <router-view> with no error. Catalog routes name the canonical href (/web) and live at the localized path (/), so reaching for the href as a path is the common miss; the warning resolves the name to the real route ('/web' … its route is '/'). Pairs with createMikserHistory(), which fixes the deep-load percent-encoding miss.
<!-- components/Clip.vue -->
<script setup>
import { useAsset } from 'mikser-io-sdk-vue'
const { url } = useAsset()
</script>
<template>
<video :src="url(clip.meta.url)"
:poster="url(clip.meta.presets.poster)"></video>
</template>asset(ref) — managed-entity lookup
Resolves a managed asset entity by id to { url, meta } | null. meta is the entity's raw meta block — opaque: mime, dimensions, duration, whatever the preset emitted. This is the lookup that needs provideAssetIndex() in a parent; without it asset(ref) returns null.
<!-- App.vue -->
<script setup>
import { provideAssetIndex } from 'mikser-io-sdk-vue'
provideAssetIndex()
</script>const { asset } = useAsset()
const { url, meta } = asset('/assets/hero.jpg') ?? {}Image-specific rendering (srcset, <img> props) is a consumer concern: read meta where you actually know the asset is an image, and build whatever element you need from it.
provideAssetIndexis only needed forasset(ref)entity lookups.url(...)works without it.
References & inline expansion
Reference fields written as $author: /authors/dick (per ADR-0007) arrive on the wire as bare href strings — document.value.meta.author === '/authors/dick'. Two ways to turn them into entity objects, picked by whether you want the deep data to stay live.
Chained useDocument — each level stays live independently. Updates to the author propagate without re-fetching the article. Best when both layers update independently.
<script setup>
import { useDocument } from 'mikser-io-sdk-vue'
const props = defineProps(['id'])
const { document: article } = useDocument(() => props.id)
const { document: author } = useDocument(() => article.value?.meta.author)
</script>
<template>
<article v-if="article && author">
<h1>{{ article.meta.title }}</h1>
<p>By {{ author.meta.name }}</p>
</article>
</template>expand on useDocument / useDocuments — the initial snapshot arrives with refs already resolved to entity objects. Best when you'd otherwise pay N round-trips on first paint (a multi-hop chain like author.organization, or arrays of refs like sections.*.image).
<script setup>
import { useDocument } from 'mikser-io-sdk-vue'
const props = defineProps(['id'])
// Single call returns article + author + author.organization + hero
// in one round-trip. The initial paint shows fully-resolved data.
const { document: article } = useDocument(() => props.id, {
expand: ['author.organization', 'hero'],
})
</script>
<template>
<article v-if="article">
<h1>{{ article.meta.title }}</h1>
<p>
By {{ article.meta.author.meta.name }}
— {{ article.meta.author.meta.organization.meta.name }}
</p>
<img :src="article.meta.hero.meta.url" :alt="article.meta.hero.meta.alt" />
</article>
</template>For lists:
<script setup>
import { useDocuments } from 'mikser-io-sdk-vue'
// All articles for /authors/dick, with each article's hero image inlined.
const { documents: articles } = useDocuments(() => ({
filter: { 'meta.component': 'article', 'meta.author': '/authors/dick' },
sort: { 'meta.date': -1 },
expand: ['hero'],
}))
</script>
<template>
<ul>
<li v-for="a in articles" :key="a.id">
<img v-if="a.meta.hero" :src="a.meta.hero.meta.url" :alt="a.meta.hero.meta.alt" />
<h2>{{ a.meta.title }}</h2>
</li>
</ul>
</template>Path forms: dot-notation walks expanded entities (author.organization); * iterates $-keyed arrays (sections.*.image); both canonical ($author) and normalized (author) segments are accepted.
Server caps default to maxDepth: 5, maxPaths: 20, maxResolved: 100 per request — configurable via catalog.expand.{...} in mikser.config.js. Exceeding any cap surfaces as a MikserError with status === 422 on the underlying call.
SSE deltas stay expanded. Both the initial snapshot AND every forward update emit fully-expanded entities. The api plugin's subscribe handler registers an engine-level runtime.refs.subscribeGraph against the subscription's filter + expand; mutations to any entity within the expansion graph (the root, the author, the author's organization, …) trigger a re-emit with the freshly-resolved tree. Reactive consumers see consistent expanded data across the lifetime of the subscription.
For ad-hoc one-shot reads that don't need to participate in the SSE subscription at all — sitemap builders, SSG enumeration, AI agent calls — drop to the underlying mikser-io-sdk-api client:
import { useMikserClient } from 'mikser-io-sdk-vue'
const client = useMikserClient()
const { items } = await client.entities('public').list({
filter: { id: props.id },
expand: ['author.organization', 'hero'],
})Missing targets or cycles silently leave the ref as a string at the deepest position — same convention as the underlying api, per ADR-0007 B6.
Reactive content cache — createReactiveCache
The composables above (useDocument, useHref / meta) live inside a component's inject context — call them in setup, never in a Pinia store action or a plain module. createReactiveCache is the escape hatch: a plain factory, not a composable. It works anywhere — Pinia store actions and getters, plain .js modules, anywhere outside a component — because it carries its own state instead of reaching for inject. Create it once and share the instance.
It's a thin Vue shell around mikser-io-sdk-api's framework-agnostic createCache. The win Vue adds is the sync, reactive read: documentSync() returns the loaded doc immediately and re-evaluates when the load lands — it bridges the core cache's subscribe to a Vue reactivity tick. That's the half a composable can't give you from a Pinia getter.
// lib/mikser.js — module scope, no component context needed
import { createReactiveCache } from 'mikser-io-sdk-vue'
import { client } from './client.js'
export const content = createReactiveCache(client.entities('public'))// a Pinia store
import { defineStore } from 'pinia'
import { content } from '../lib/mikser.js'
export const useSystemStore = defineStore('system', {
actions: {
// async — load + memoize. Use from actions.
loadProducts() {
return content.document('/system/products', { expand: ['products.*.video'] })
},
},
getters: {
// sync + reactive — re-evaluates when the load lands. Use from getters.
translationErrors: () => content.documentSync('/system/translation')?.meta.errors,
},
})API
const content = createReactiveCache(client.entities('public')) returns:
| Member | What it does |
|---|---|
| content.document(href, { expand } = {}) | async — loads + memoizes the doc at that logical meta.href, resolves to the doc (items[0]) or null. References resolve by default ($ wildcard); pass expand: [] to opt out or a path list to narrow. Use from store actions. |
| content.documentSync(href, { expand } = {}) | sync + reactive — returns the loaded doc or null, and re-evaluates when the load lands. Same default expand as document(). Use from Pinia getters / sync template helpers — this is the half composables can't do. |
| content.load(query, opts) / content.read(query) | The same async / sync pair, but for arbitrary list queries (returns the full envelope). |
| content.invalidate(query?) | Drop one entry, or the whole cache when called with no argument. |
| content.cache | The underlying mikser-io-sdk-api cache, if you need it. |
The cache key folds in expand — a doc fetched with expand is a different shape from the same doc without, so they're distinct entries.
When to reach for it vs. the live href index
It pairs with the live href index (useHref / meta), it doesn't replace it:
| | meta(ref) (live href index) | createReactiveCache |
|---|---|---|
| Freshness | Always-fresh — backed by an SSE subscription | Load-once — re-fetch only on `invali
