# mikser-io-sdk-vue

> Vue 3 composables and router integration for mikser-io — live documents, multilingual hrefs, asset resolution

Latest version **11.0.2** (published 2026-10-15) · MIT license · 0 weekly downloads

## Install

```sh
npm install mikser-io-sdk-vue
pnpm add mikser-io-sdk-vue
yarn add mikser-io-sdk-vue
bun add mikser-io-sdk-vue
```

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 11.0.2 |
| Published | 2026-10-15 |
| First published | 2026-05-31 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=18 |
| Dependencies | 1 |
| Unpacked size | 128.3 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| Maintainers | almero-digital-marketing |
| Keywords | mikser, mikser-io, sdk, vue, vue3, composables, vue-router, i18n, multilingual |

## Links

- npm: https://www.npmjs.com/package/mikser-io-sdk-vue
- Repository: https://github.com/almero-digital-marketing/mikser-io-sdk-vue
- Homepage: https://github.com/almero-digital-marketing/mikser-io-sdk-vue#readme
- Issues: https://github.com/almero-digital-marketing/mikser-io-sdk-vue/issues
- npm.io page: https://npm.io/package/mikser-io-sdk-vue

## Dependencies (1)

- [@vue/server-renderer](https://npm.io/package/@vue/server-renderer.md) ^3.5.35

## Recent versions

- 11.0.2 (latest) — 2026-10-15
- 11.0.1 — 2026-09-06
- 2.10.0 — 2026-06-30
- 2.9.0 — 2026-06-30
- 2.7.0 — 2026-06-30
- 2.6.0 — 2026-06-29
- 2.5.1 — 2026-06-29
- 2.5.0 — 2026-06-29
- 2.4.0 — 2026-06-29
- 2.3.0 — 2026-06-01
- 2.2.3 — 2026-06-01
- 2.2.2 — 2026-06-01
- 2.2.0 — 2026-06-01
- 2.1.1 — 2026-05-31
- 2.1.0 — 2026-05-31
- … 4 more at https://npm.io/package/mikser-io-sdk-vue/versions

## README

# mikser-io-sdk-vue

**Wire a Vue 3 app to a [mikser-io](https://github.com/almero-digital-marketing/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/`](./examples) for the three patterns side-by-side.

**Typed at the seam.** Pair with [`mikser-io-schemas`](https://github.com/almero-digital-marketing/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`](https://github.com/almero-digital-marketing/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

```bash
npm install mikser-io-sdk-vue mikser-io-sdk-api vue vue-router
```

`vue-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.

```js
// 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:

```js
// 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`](https://github.com/almero-digital-marketing/mikser-io-sdk-api#initialurl--pair-with-the-data-plugin-for-fast-first-paint) for the client side, and [`examples/mikser-content/mikser.config.js`](./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'` in `vite.config.js`** — top-level `await` is ES2022 syntax. Vite 5's default `'modules'` target is below the TLA cutoff and `vite build` errors out otherwise. Every evergreen browser (Chrome 89+, Firefox 89+, Safari 15+, Edge 89+) runs es2022 natively. Alternatively, wrap the bootstrap in an `async function main() { … } main()` IIFE — no build-target change needed.
>
> **2. A loading state in `index.html`** — `await seeded` blocks 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:
>
> ```html
> <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-spa`](./examples/pure-spa) and [`examples/hybrid-ssg`](./examples/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-renders
```

The `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.)

```yaml
# 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.

```vue
<!-- 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.

```vue
<!-- 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.

```vue
<!-- 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.

```vue
<!-- 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 → `ArticleIndex` receives the `create` event and the new card appears at the top of the list, no refresh.
- Click it → router dispatches via `meta.component: 'article'` → `ArticleView` mounts → its `useDocument` subscription opens → editor edits the body → the article re-renders in place.
- Browse to `/en/products/desk-lamp` → router dispatches via `meta.component: 'product'` → `ProductView` mounts with image, price, CTA — visibly nothing like an article.
- Editor toggles `in_stock: false` in the front-matter → `ProductView` re-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/`](./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`](./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`](./examples/pure-spa)** (scenario A) | Vite + Vue + `useMikserRoutes` against your own router |
> | **[`examples/dynamic-spa`](./examples/dynamic-spa)** (scenario D) | Same shape as `pure-spa` but with a catch-all + `useDocumentByRoute` |
> | **[`examples/hybrid-ssg`](./examples/hybrid-ssg)** (scenario B) | Two Vite configs (public + editor), shared `route-mapping.js` |
> | **[`examples/islands`](./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.

```js
// 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`](./examples/pure-spa)** — clone, `npm install`, set `VITE_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:

```js
// 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:

```js
// 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 build
```

**Trade-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`](./examples/hybrid-ssg)** — the load-bearing file is `src/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:

```html
<!-- 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:

```js
// 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:

```vue
<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`](./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.

```js
// 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')
```

```vue
<!-- 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](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md) 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`](./examples/dynamic-spa)** — same shape as `pure-spa` but 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?)`

```js
const { document, loading, error, refresh } = useDocument(() => props.entityId)
```

- `id` can be a string, a Ref, or a getter. When it changes, the subscription re-subscribes for the new id.
- `document` is 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`](https://github.com/almero-digital-marketing/mikser-io-schemas), which generates one `XxxMeta` alias per layout from Zod schemas in the mikser project:

```ts
import type { MetaByLayout } from '../mikser-content/entities'

const { document } = useDocument<{ meta: MetaByLayout<'article'> }>(id)
// document.value.meta.title / .author / .summary are all typed
```

Without `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?)`

```js
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,
})
```

- `query` can be a static object, a Ref, or a getter. Deep-watched — when the filter / sort / fields / limit change, the subscription re-evaluates.
- `documents` is 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.

```js
// 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 */)
```

```js
// 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](#references--inline-expansion) 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 `['$']`:

```js
// 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:

```js
// 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 })
}
```

```js
// App.vue                       → provideRouteDocument([])   // neutral: head + plain fields
// views/Home.vue (renders refs) → provideRouteDocument()     // default ['$']: refs resolved
// a child component             → useCurrentDocument()       // reads whichever provide is nearest
```

One 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 }`.

```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. `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')
```

- **`mapRoute` must return routes with a `name`** — 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; `addRoute` overwrites on duplicate names.
- `seeded` resolves 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.

```js
// 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/about` vs `/fr/a-propos` vs `/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-uns` in DE, `/a-propos` in FR, `/about` in EN) without per-language code paths
- Reactive locale switching — change `locale.value`, every `:to` updates
- Broken-link visibility — `href('/typo')` returns `/typo` unchanged, so QA sees it in the DOM instead of hitting silent `undefined` failures
- 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` |

```yaml
# 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:

```vue
<!-- 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

```vue
<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.

```vue
<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.

```vue
<!-- 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:

```vue
<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.

```vue
<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:

```js
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:

```vue
<!-- 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:

```js
// 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.

```vue
<!-- 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.

```vue
<!-- App.vue -->
<script setup>
import { provideAssetIndex } from 'mikser-io-sdk-vue'
provideAssetIndex()
</script>
```

```js
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.

> `provideAssetIndex` is only needed for `asset(ref)` entity lookups. `url(...)` works without it.

## References & inline expansion

Reference fields written as `$author: /authors/dick` (per [ADR-0007](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0007-references-declaration-and-expansion.md)) 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.

```vue
<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`).

```vue
<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:

```vue
<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:

```js
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.

```js
// 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'))
```

```js
// 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 `invalidate` |
| `expand` | No | Yes |
| Readable from | Components only (it's a composable) | Anywhere — stores, modules, components |
| Best for | Changing feeds, anything edited live | System docs, nav, settings — read often, change rarely |

Reach for `live()` / `meta()` when the content changes under you and the UI must track it. Reach for `createReactiveCache` for the load-once, expand-capable, read-from-anywhere content that backs a store.

## Semantic search — `createMikserVectorPlugin` + `useSimilar`

Bridges the `mikser-io-sdk-vector` client into Vue. Same provide/inject shape as the documents plugin; the search composable handles debounce + stale-result discard so a fast-typing user doesn't see older results clobber newer ones.

```js
// main.js — install the vector plugin alongside the documents one
import { createClient as createVectorClient } from 'mikser-io-sdk-vector'
import { createMikserVectorPlugin } from 'mikser-io-sdk-vue'

const similar = createVectorClient({ baseUrl: import.meta.env.VITE_MIKSER_URL })
app.use(createMikserVectorPlugin({ client: similar }))
```

```vue
<!-- components/SearchBox.vue -->
<script setup>
import { ref } from 'vue'
import { useSimilar } from 'mikser-io-sdk-vue'

const q = ref('')
const { results, loading } = useSimilar('documents', () => q.value, {
    limit:     10,
    debounce:  200,    // ms after the last keystroke before firing
    minLength: 2,      // skip the request below this length
})
</script>

<template>
    <input v-model="q" placeholder="Search…" />
    <p v-if="loading">Searching…</p>
    <ul>
        <li v-for="hit in results" :key="hit.id">
            <a :href="hit.id">{{ hit.data?.title }}</a>
            <small>distance: {{ hit.distance.toFixed(3) }}</small>
        </li>
    </ul>
</template>
```

- **`results` is reactive**; updates push to `<v-for>` in place.
- **`loading`** is true only while a request is in flight, not during the debounce wait. Use it to show a spinner, not a placeholder.
- **`error`** is populated when `findSimilar()` rejects. Branch on it to render a fallback.
- **`refresh()`** forces a fresh request against the current query. Use after the vector store has been updated server-side.

`mikser-io-sdk-vector` is an **optional** runtime dependency — the framework SDK doesn't import it. Install it only if you use semantic search:

```bash
npm install mikser-io-sdk-vector
```

The hit shape is generic on the embedded payload — type the `data` field for typed templates:

```ts
type ProductHit = { title: string; sku: string; price: number }
const { results } = useSimilar<ProductHit>('products', () => q.value)
//        ↑ results.value[0].data is now ProductHit
```

## What this library doesn't include

Deliberately. Each of these is a per-project decision:

- **A `DocumentPage` component.** Every project renders content differently (markdown engine, layouts, sanitization). `useDocument()` gives you the data; you write the component.
- **A markdown renderer.** Use `markdown-it`, `marked`, or whatever your project standardizes on. If you render Markdown server-side via the `render-markdown` plugin, `document.content` is already HTML.
- **A layout switcher.** Some teams use `<router-view :class="$route.meta.layout">`; some use named layouts; some pick via `<component :is="...">`. Stay neutral.
- **Auth / token UI.** Tokens are configuration — `createClient({ token })` handles them.

## TypeScript

Full types ship with the package. Most generics let you narrow the document shape:

```ts
import type { ListEnvelope } from 'mikser-io-sdk-api'

interface Article {
    id: string
    meta: { title: string; date: string; summary?: string }
}

const { documents } = useDocuments<Article>({
    filter: { 'meta.collection': 'articles' },
})

// documents.value is Ref<Article[]>; meta.title autocompletes.
```

The router-related types are forwarded from `vue-router` so `mapRoute` returns `RouteRecordRaw` directly.

## License

MIT

---
_Source: https://npm.io/package/mikser-io-sdk-vue · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
