Vue
@taujs/vue
Section titled “@taujs/vue”Vue SSR (Server-Side Rendering) with Streaming Support
A lightweight, Vue 3 SSR library with streaming capabilities, built for modern TypeScript applications. Designed as part of the taujs (τjs) ecosystem but fully standalone and runtime-agnostic.
τjs @taujs/vue
Table of Contents
Section titled “Table of Contents”- Overview
- Installation
- Core Concepts
- Consuming SSR data
- API Reference
- App customisation with setupApp
- Lazy hydration
- Handling hydration mismatches
- Usage in the taujs ecosystem
- Standalone usage
- TypeScript support
- Best practices
Overview
Section titled “Overview”@taujs/vue provides:
- Streaming SSR with
renderToSimpleStreamfrom@vue/server-renderer - Static SSR with
renderToStringfor simpler use cases and<Teleport>support - Automatic hydration with built-in data synchronisation and CSR fallback
- Vue-native data store using reactive refs,
provide/injectand<Suspense> - App-instance customisation via
setupApp- the integration point for Pinia, vue-i18n, directives and global components - Robust error handling routed through
app.config.errorHandler - Runtime agnostic - works with Node.js, Fastify, Express, or any HTTP server
@taujs/vue mirrors the contract and capability of @taujs/react - the same render surface, the same server behaviour, the same hydration beacon events - but it is Vue-native, not a React transliteration. Where React throws promises and uses useSyncExternalStore, Vue uses reactivity, async setup and <Suspense>. The documented divergences (for example, useSSRStore returning the store) are deliberate and called out in place.
Installation
Section titled “Installation”npm install @taujs/vue vue @vue/server-rendererRecommended dependency ranges:
{ "dependencies": { "@taujs/vue": "latest", "@vue/server-renderer": "^3.5.0", "vue": "^3.5.0" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.0" }}Core Concepts
Section titled “Core Concepts”1. SSR data store
Section titled “1. SSR data store”The SSRStore is a type-safe, reactive container that synchronises data between server and client. Unlike the React store, it exposes Vue refs and a ready promise rather than throwing snapshots:
type SSRStoreStatus = "pending" | "success" | "error";
type SSRStore<T> = { /** Reactive data ref for Vue components. */ data: Ref<T | undefined>; status: Ref<SSRStoreStatus>; lastError: Ref<Error | undefined>; /** Resolves when initial data is available (or rejects on error). */ ready: Promise<void>; /** Vue-safe: returns data or undefined (never throws). */ getSnapshot: () => T | undefined; setData: (newData: T) => void; /** Subscription API for non-Vue consumers / tests. */ subscribe: (callback: () => void) => () => void;};2. Renderer pattern
Section titled “2. Renderer pattern”The createRenderer function creates a reusable renderer from your root component and configuration, returning both a static and a streaming render method:
const { renderSSR, renderStream } = createRenderer({ appComponent: App, // a Vue component, or a render function headContent: ({ data, meta }) => `<title>${escapeHtml(data.title)}</title>`, streamOptions: { shellTimeoutMs: 10000 },});3. Hydration strategy
Section titled “3. Hydration strategy”Client-side hydration automatically:
- Detects SSR data from
window.__INITIAL_DATA__ - Creates a synchronised store and provides it via
SSRStoreProvider - Hydrates the Vue tree with
createSSRApp(...).mount() - Falls back to a fresh CSR mount if no SSR data exists
Consuming SSR data
Section titled “Consuming SSR data”There are two idioms for reading store data inside a component, in order of preference. This preference order is the one stated in the package’s own JSDoc - reach for Suspense first.
Suspense (recommended)
Section titled “Suspense (recommended)”await the data in an async setup under a <Suspense> boundary. The render blocks until data resolves, so the resolved value is present in the server-rendered HTML:
<script setup lang="ts">import { useSSRDataAsync } from "@taujs/vue";
type GreetingData = { message: string; timestamp: string };
const data = await useSSRDataAsync<GreetingData>();</script>
<template> <div>{{ data.message }}</div></template>useSSRDataAsync<T>() returns Promise<T>, so data is the resolved value (not a ref) - use it directly in the template.
Fallback rendering
Section titled “Fallback rendering”Read a non-throwing computed and guard with v-if. useSSRData<T>() returns ComputedRef<T | undefined> - undefined while pending, the value once ready - so you can render a fallback without forcing Suspense:
<script setup lang="ts">import { useSSRData } from "@taujs/vue";
type GreetingData = { message: string; timestamp: string };
const data = useSSRData<GreetingData>();</script>
<template> <section v-if="data" class="card"> <p>{{ data.message }}</p> <p>Generated at: {{ new Date(data.timestamp).toLocaleString() }}</p> </section> <section v-else class="card"> <p>Loading greeting…</p> </section></template>This idiom is a good fit for ssr routes, where the server has already resolved the data before rendering begins, so the v-else fallback is never actually shown in the SSR output.
API Reference
Section titled “API Reference”Server-side rendering
Section titled “Server-side rendering”createRenderer<T, R, H>(options)
Section titled “createRenderer<T, R, H>(options)”Creates a renderer instance with streaming and static SSR capabilities.
Type signature:
function createRenderer< T extends Record<string, unknown> = Record<string, unknown>, R = unknown, H extends Record<string, unknown> = Record<string, unknown>>(options: { /** A Vue component, or a function returning a VNode. Rendered with props { location, routeContext? }. */ appComponent: Component | ((props: { location: string; routeContext?: R }) => VNode); headContent: (ctx: HeadContext<T, R, H>) => string; streamOptions?: StreamOptions; logger?: LoggerLike; enableDebug?: boolean; setupApp?: (app: App) => void;}): { // NB the RETURNED functions' host-facing parameters are deliberately BROAD (a renderer must // stay assignable to @taujs/server's renderer-agnostic contracts regardless of your generics): // T, R and H apply INSIDE your callbacks (appComponent, headContent), not at this boundary. renderSSR: ( initialData: Record<string, unknown>, location: string, meta?: Record<string, unknown>, signal?: AbortSignal, opts?: { logger?: LoggerLike; routeContext?: unknown; headData?: Record<string, unknown> } ) => Promise<{ headContent: string; appHtml: string; aborted: boolean; teleports?: Record<string, string>; }>; renderStream: ( writable: Writable, callbacks: RenderCallbacks<T>, initialData: | Record<string, unknown> | Promise<Record<string, unknown>> | (() => Promise<Record<string, unknown>>), location: string, bootstrapModules?: string, meta?: Record<string, unknown>, cspNonce?: string, signal?: AbortSignal, opts?: StreamOptions & { logger?: LoggerLike; routeContext?: unknown; headData?: Record<string, unknown> } ) => { abort: () => void; done: Promise<void> };};Typing model: the generics narrow your OWN code -
appComponentreceivesrouteContextasR,headContentreceivesdata: TandheadData?: H. The returned render functions accept broad records at the host boundary, so standalone callers do not get compile-time narrowing on the values they pass in; the route config (with@taujs/server) or your own call-site discipline is the type authority for those.
Parameters:
appComponent: Your Vue root. Pass the component directly (it receives{ location, routeContext? }as props) or a render function returning a VNode.headContent: Generates HTML head content from{ data, headData?, meta, routeContext? }. Note: in streaming mode the data snapshot is usually still pending when the head is built - guard optionaldatafields, lean onmeta/routeContext, or declareattr.headon the route so the host resolves DYNAMIC head data before the render and passes it asheadData(typed bycreateRenderer’s third genericH).headDataisundefinedwhen the route declares noattr.headand when the head loader degraded (deadline expiry, or anoptionalloader failure) - always handle it. Vue’s head stays SINGLE-BUILD, pre-render;headDatawidens what that one build can see. Escape everyheadData/data-derived value withescapeHtml.streamOptions.shellTimeoutMs: Time-to-first-content watchdog in ms (default10000). See Streaming semantics.logger: Custom logger implementation (LoggerLike).enableDebug: Enable detailed logging (defaultfalse).setupApp: Configure the per-requestAppinstance (app.use, directives, provides). See App customisation.
Returns an object with two rendering methods: renderSSR (static, returns complete HTML plus teleports) and renderStream (streaming, pipes to a writable).
Per-call opts.headData: the route’s resolved attr.head payload. With @taujs/server this
is passed for you (only when the route declares attr.head and its loader resolved); standalone
hosts may pass their own pre-resolved head data. Reaches headContent typed as H.
Worked example: dynamic head data on a streamed route
import { createRenderer, escapeHtml } from "@taujs/vue";import App from "./App.vue";
type HeadData = { ogTitle?: string; ogDescription?: string };
export const { renderSSR, renderStream } = createRenderer< Record<string, unknown>, unknown, HeadData>({ appComponent: App, // headData is undefined when the route declares no attr.head, and when the head loader // degraded (deadline expiry, or an optional loader failure) - always handle it. headContent: ({ headData, meta }) => ` <title>${escapeHtml(headData?.ogTitle ?? (meta.title as string | undefined) ?? "My App")}</title> ${ headData?.ogDescription ? `<meta name="description" content="${escapeHtml(headData.ogDescription)}">` : "" } `,});// taujs.config.ts - the route side (with @taujs/server){ path: '/streaming', attr: { render: 'streaming', meta: { title: 'My App' }, // static fallback layer data: serviceData('content', 'greet', () => ({ name: 'Vue' })), head: { data: serviceData('content', 'greetHead', () => ({ name: 'Vue' })) }, // resolved pre-render },}The HeadContext, RenderCallbacks and StreamOptions types:
type HeadContext< T extends Record<string, unknown> = Record<string, unknown>, R = unknown, H extends Record<string, unknown> = Record<string, unknown>> = { data: T; headData?: H; // the route's attr.head payload, resolved by the host before the render meta: Record<string, unknown>; routeContext?: R;};
type RenderCallbacks<T> = { /** Receives the head string; the server writes it into <head>. Return value is ignored. */ onHead?: (head: string) => void; onShellReady?: () => void; onAllReady?: (data: T) => void; /** @deprecated Legacy alias of onAllReady, kept for @taujs/react parity. Use onAllReady. */ onFinish?: (data: T) => void; onError?: (err: unknown) => void;};
type StreamOptions = { /** Time-to-first-content watchdog, in ms (default 10000). */ shellTimeoutMs?: number;};Route-aware rendering with routeContext
Section titled “Route-aware rendering with routeContext”@taujs/vue doesn’t know anything about your router, but it can carry route information from the server into both your Vue tree (appComponent) and your head generator (headContent) through the generic R and a routeContext value:
type RouteContext = { name: "home" | "article"; path: string; params: Record<string, string>;};
type PageData = { title: string; description?: string };
const { renderSSR, renderStream } = createRenderer<PageData, RouteContext>({ appComponent: App, // receives { location, routeContext } as props headContent: ({ data, meta, routeContext }) => { const baseTitle = data.title ?? (meta.title as string | undefined) ?? "My App"; const suffix = routeContext?.name === "article" ? " · Article" : ""; return `<title>${escapeHtml(baseTitle)}${suffix}</title>`; },});On the server you pass routeContext per request via the final opts argument of renderSSR / renderStream.
renderSSR()
Section titled “renderSSR()”Renders the complete application to a string (static SSR). Returns { headContent, appHtml, aborted, teleports? }.
const { renderSSR } = createRenderer<{ msg: string }>({ appComponent: () => h(App), headContent: ({ data }) => `<title>${escapeHtml(data?.msg ?? "no-data")}</title>`,});
const { headContent, appHtml, teleports } = await renderSSR( { msg: "hello" }, "/", { requestId: "123" } // meta);Teleports
Section titled “Teleports”renderSSR collects <Teleport> content into its result’s teleports map, keyed by target selector (for example '#modal'). The teleported content is buffered out of appHtml, not left inline:
const App = defineComponent({ setup() { const data = useSSRData<{ msg: string }>(); return () => h("div", { id: "app" }, [ `main:${data.value?.msg ?? ""}`, h(Teleport, { to: "#modal" }, [h("span", "teleported-body")]), ]); },});
const result = await renderSSR({ msg: "hi" }, "/");result.appHtml; // contains "main:hi", NOT "teleported-body"result.teleports?.["#modal"]; // contains "teleported-body"renderStream()
Section titled “renderStream()”Streams the application with @vue/server-renderer’s renderToSimpleStream. When bootstrapModules is provided, the renderer emits the client bootstrap <script type="module" ... async> tag before ending the stream, so a streamed route hydrates. The cspNonce, when present, is applied to that bootstrap tag.
const { done, abort } = renderStream( res, // Node.js Writable { onHead: (head) => { // Server writes `head` into <head>; the renderer never writes head bytes. }, onShellReady: () => { // Fires on the first streamed content byte. }, onAllReady: (data) => { // Fires once the store settles; the resolved data is delivered here. }, onError: (err) => { console.error("Stream error:", err); }, }, { msg: "hello" }, // initialData: a record, a promise of one, or a lazy factory "/streaming", // location "/assets/entry-client.js", // bootstrapModules { requestId: "123" }, // meta "nonce-abc123" // cspNonce);
await done;Streaming semantics
Section titled “Streaming semantics”Vue streaming is honest and in-order. It emits chunked HTML in document order and blocks at async boundaries (an async setup under <Suspense> stalls the stream until it resolves). There is no React-style out-of-order shell with fallback patching - nothing is streamed early and rewritten later.
Because of that:
shellTimeoutMsis a time-to-first-content watchdog, not a shell deadline. It starts before rendering and is cleared on the first streamed chunk; if no content is produced before it expires, the stream is aborted with a fatal error routed toonError.onShellReady()fires on the first content byte, which is the honest meaning of “shell ready” for an in-order stream.onAllReady(data)(and its deprecated aliasonFinish(data)) fire once the store settles, delivering the resolved data.
Per-call options (opts)
Both methods accept a final opts argument:
logger: Optional per-call logger (overrides the one passed tocreateRenderer).routeContext: Per-request route context forwarded intoappComponentandheadContent.renderStreamadditionally acceptsshellTimeoutMshere to override the renderer default per call.
Cancellation and lifecycle
renderStream accepts an AbortSignal (a triggered signal is a benign cancel). It returns { abort, done }: abort() performs a manual benign abort, and done resolves on normal completion (or rejects on a fatal error). Client disconnects (ECONNRESET, EPIPE, socket hang up, aborted, premature close) are classified as benign and never surface as fatal errors.
Client-side hydration
Section titled “Client-side hydration”hydrateApp<T>(options)
Section titled “hydrateApp<T>(options)”Hydrates a server-rendered Vue application on the client, or mounts a fresh CSR app if no SSR data is present.
Type signature (the options object is typed as HydrateAppOptions<T>):
function hydrateApp<T>(options: { /** Root app component, or a render function. */ appComponent: Component | (() => VNode); rootElementId?: string; // default 'root' enableDebug?: boolean; logger?: LoggerLike; dataKey?: string; // default '__INITIAL_DATA__' onHydrationError?: (err: unknown) => void; /** Receives the App instance (divergence from react, whose callbacks take no args). */ onStart?: (app: App) => void; /** Receives the App instance (divergence from react, whose callbacks take no args). */ onSuccess?: (app: App) => void; setupApp?: (app: App) => void;}): void;Example:
import { hydrateApp } from "@taujs/vue";import App from "./App.vue";
hydrateApp({ appComponent: App, rootElementId: "root", enableDebug: import.meta.env.DEV, onHydrationError: (err) => { console.error("Hydration failed:", err); }, onSuccess: (app) => { console.log("App hydrated successfully!", app); },});Behaviour:
- If
window[dataKey]exists: hydrates the SSR markup withcreateSSRApp(...).mount(rootEl). - If
window[dataKey]is missing: clears any stale markup and mounts a fresh CSR app. No hydration callbacks or beacon events fire - a CSR mount is not a hydration. - Automatically handles
DOMContentLoadedtiming. - Wraps your app in
SSRStoreProvideron both the hydrate and CSR paths.
Error reporting (Vue-native). Unlike React, which surfaces hydration failures as synchronous throws, Vue reports them through app.config.errorHandler and recoverable warnings. So hydrateApp installs an error handler before mount() and treats errors during the hydration phase as hydration failures, routing them to onHydrationError. In enableDebug mode it also forwards Vue hydration-mismatch warnings to the logger. A user handler installed via setupApp still fires alongside τjs’s routing.
Devtools beacon (dev-only). On the hydrate path, hydrateApp emits hydration:start, hydration:success and hydration:error to window.__TAUJS_DEVTOOLS_HOOK__ around your callbacks (internal emission first, user callback second). The hook is only ever set by the server-injected dev script, is absent in production, and emission can never throw into hydration. The CSR-fallback path emits nothing.
HTML template requirements. Your server-rendered HTML must include the root element, an initial data script, and the client bundle:
<main id="root"><!-- SSR content here --></main><script> window.__INITIAL_DATA__ = { message: "Hello" };</script><script type="module" src="/assets/entry-client.js"></script>Data store
Section titled “Data store”createSSRStore<T>(initial)
Section titled “createSSRStore<T>(initial)”Creates a store for managing SSR data synchronisation. Accepts synchronous data, a promise, or a lazy promise factory:
import { createSSRStore } from "@taujs/vue";
// Synchronous data → status is 'success' immediately.const store1 = createSSRStore({ a: 1 });
// Promise → 'pending' until it settles.const store2 = createSSRStore(Promise.resolve({ b: 2 }));
// Lazy promise factory → 'pending' until it settles.const store3 = createSSRStore(() => Promise.resolve({ c: 3 }));
await store2.ready;store2.status.value; // 'success'store2.getSnapshot(); // { b: 2 }The store is reactive: data, status and lastError are shallowReadonly refs, so getSnapshot() and data.value preserve object identity with the value you passed in. On a rejected promise, status becomes 'error', lastError holds the normalised Error, and ready rejects. Calling setData on a still-pending or failed store recovers it: it flips status to 'success' and resolves ready.
Inside components you rarely call createSSRStore directly - @taujs/server and hydrateApp create and provide the store for you. You consume it with the composables below.
useSSRStore<T>()
Section titled “useSSRStore<T>()”Returns the store (SSRStore<T>), not the resolved value.
import { useSSRStore } from "@taujs/vue";
const store = useSSRStore<{ user: string }>();store.status.value; // 'pending' | 'success' | 'error'await store.ready;store.getSnapshot(); // { user: "Alice" } | undefinedMust be called within an SSRStoreProvider - it throws "useSSRStore must be used within a SSRStoreProvider" otherwise.
useSSRData<T>()
Section titled “useSSRData<T>()”Returns ComputedRef<T | undefined> - the non-throwing fallback path. undefined while pending, the value once ready. Pair with v-if (see Fallback rendering).
const data = useSSRData<{ message: string }>();// data.value is undefined while pending, then the payload.useSSRDataAsync<T>()
Section titled “useSSRDataAsync<T>()”Returns Promise<T> - the recommended Suspense path. await it in an async setup under <Suspense> (see Suspense). It awaits store.ready, then returns the snapshot; if the snapshot is still undefined after ready resolves it throws, and if the underlying data load rejected, the await rejects with that error (catchable in setup or surfaced through the <Suspense> boundary).
const data = await useSSRDataAsync<{ message: string }>();useSSRReady() and useSSRStatus()
Section titled “useSSRReady() and useSSRStatus()”useSSRReady()returns the store’sreadypromise (Promise<void>), for awaiting in async setup without needing the value.useSSRStatus()returnsComputedRef<SSRStoreStatus>, a reactive view of the store’s'pending' | 'success' | 'error'status.
const status = useSSRStatus();// status.value === 'pending' while loading, 'success' once ready.
await useSSRReady();// data is now available via useSSRStore().getSnapshot()SSRStoreProvider and SSR_STORE_KEY
Section titled “SSRStoreProvider and SSR_STORE_KEY”SSRStoreProvider is a Composition-API provider component that makes a store available to descendants (parity with @taujs/react’s SSRStoreProvider). @taujs/vue wraps your app in it automatically on every render and hydration path; you only need it directly when composing stores by hand or in tests:
import { h } from "vue";import { createSSRStore, SSRStoreProvider } from "@taujs/vue";
const store = createSSRStore({ a: 1 });
const Root = { render: () => h(SSRStoreProvider, { store }, { default: () => h(Child) }),};SSR_STORE_KEY is the InjectionKey the provider uses. It is exported for advanced interop (a custom provider, or reading the store outside the composables via inject(SSR_STORE_KEY)).
Vite plugin
Section titled “Vite plugin”pluginVue(options?)
Section titled “pluginVue(options?)”A thin passthrough over @vitejs/plugin-vue, for parity with @taujs/react/plugin. Options are forwarded verbatim.
function pluginVue(opts?: Parameters<typeof vue>[0]): PluginOption;import { defineConfig } from "vite";import { pluginVue } from "@taujs/vue/plugin";
export default defineConfig({ plugins: [pluginVue()],});Within the taujs ecosystem you register it per app in taujs.config.ts instead - see Usage in the taujs ecosystem.
App customisation with setupApp
Section titled “App customisation with setupApp”Vue applications are configured through the App instance (app.use, app.directive, global components, app.provide). React composes the equivalent in JSX, so @taujs/react never needed a hook - but @taujs/vue creates its apps internally, so setupApp is the sanctioned integration point for Pinia, vue-i18n, custom directives and global components under τjs SSR.
setupApp?: (app: App) => void is accepted by both createRenderer and hydrateApp. It runs after the internal store provide and before render/mount, on every path: renderSSR, renderStream, hydration, and the CSR fallback.
Constraints (so the identical function runs verbatim on server and client):
- Synchronous only - no promise form.
- No
window/DOM access - it runs on the server too. - Idempotent per app instance - a fresh
Appis created per request and per mount.
A throwing setupApp is treated as an application error and routed to the path’s error channel (onError + fatal abort on the server; onHydrationError on the client) - never swallowed.
// A plugin installed via setupApp is usable by any rendered component.import type { App } from "vue";
const setupApp = (app: App) => { app.provide(MESSAGE_KEY, "from-plugin"); app.directive("focus", { /* getSSRProps for SSR-visible directives */ });};
// Serverconst { renderSSR } = createRenderer({ appComponent: App, headContent, setupApp });// Client - the SAME functionhydrateApp({ appComponent: App, setupApp });Because setupApp runs identically on server and client, it is where you install Pinia. τjs adds no state-serialisation channel - data flow stays request-first. Seed Pinia store state from the SSR data you already receive (useSSRData / useSSRDataAsync), which arrives via window.__INITIAL_DATA__:
// setup-app.ts - shared by entry-server and entry-clientimport { createPinia } from "pinia";import type { App } from "vue";
export const setupApp = (app: App) => { app.use(createPinia());};<!-- A component seeds the Pinia store from request-first SSR data. --><script setup lang="ts">import { useSSRDataAsync } from "@taujs/vue";import { useUserStore } from "./stores/user";
type PageData = { user: { id: string; name: string } };
const data = await useSSRDataAsync<PageData>();const userStore = useUserStore();userStore.$patch({ user: data.user }); // seed from SSR data - no extra channel</script>
<template> <div>{{ userStore.user.name }}</div></template>The same seed runs during SSR (populating server-rendered markup) and during hydration (populating the client store from the identical __INITIAL_DATA__ payload), so the two never diverge.
Lazy hydration
Section titled “Lazy hydration”Vue 3.5’s lazy-hydration strategies compose with hydrateApp. Wrap a component with defineAsyncComponent and pass a hydrate strategy; the root app that hydrateApp mounts will defer that component’s hydration accordingly. This is a per-component concern inside your tree - hydrateApp itself is unchanged.
<script setup lang="ts">import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery,} from "vue";
// Hydrate when the element scrolls into view.const Comments = defineAsyncComponent({ loader: () => import("./Comments.vue"), hydrate: hydrateOnVisible(),});
// Hydrate when the browser is idle (optionally with a timeout).const Sidebar = defineAsyncComponent({ loader: () => import("./Sidebar.vue"), hydrate: hydrateOnIdle(2000),});
// Hydrate on first interaction of the given event(s).const Widget = defineAsyncComponent({ loader: () => import("./Widget.vue"), hydrate: hydrateOnInteraction("click"),});
// Hydrate when a media query matches.const DesktopNav = defineAsyncComponent({ loader: () => import("./DesktopNav.vue"), hydrate: hydrateOnMediaQuery("(min-width: 768px)"),});</script>
<template> <Comments /> <Sidebar /> <Widget /> <DesktopNav /></template>Each strategy (hydrateOnVisible, hydrateOnIdle, hydrateOnInteraction, hydrateOnMediaQuery) is imported from vue and requires Vue ≥ 3.5. Use them to shrink the client’s initial hydration cost while keeping server-rendered markup intact.
Handling hydration mismatches
Section titled “Handling hydration mismatches”For content that is intentionally different between server and client - timestamps, locale-formatted dates, randomised values - annotate the element with Vue’s official data-allow-mismatch attribute to suppress the hydration-mismatch warning for it:
<template> <!-- Suppress the mismatch warning for this deliberately client-varying text. --> <span data-allow-mismatch>{{ new Date().toLocaleString() }}</span></template>data-allow-mismatch accepts an optional value to narrow what is allowed to differ: text, children, class, style, or attribute (for example data-allow-mismatch="text"). Use it sparingly and only for genuinely intentional divergence - it silences a correctness signal, so a stray mismatch elsewhere in the subtree can go unnoticed.
Usage in the taujs ecosystem
Section titled “Usage in the taujs ecosystem”With @taujs/server, you don’t call the render functions directly. You define rendering once in entry-server, hydration once in entry-client, and declare routes and data in taujs.config.ts; the server orchestrates everything.
taujs.config.ts - register pluginVue() per app and declare each route’s rendering strategy:
import { defineConfig } from "@taujs/server/config";import { pluginVue } from "@taujs/vue/plugin";
export default defineConfig({ server: { port: 5173, host: "localhost", hmrPort: 5174 }, apps: [ { appId: "main", entryPoint: "", plugins: [pluginVue()], routes: [ { path: "/", attr: { render: "ssr", hydrate: true, // Direct service invocation: standard SSR data: async (params, ctx) => { return ctx.call("example", "greet", { name: "SSR" }); }, }, }, { path: "/streaming", attr: { render: "streaming", hydrate: true, // Descriptor-based data: resolved by the server data: async (params) => ({ args: { name: "Streaming" }, serviceName: "example", serviceMethod: "greet", }), // meta recommended for streaming routes for SEO/social and render timing meta: { title: "τjs - Streaming", description: "Streaming SSR route (Suspense progressively reveals content).", }, }, }, ], }, ],});entry-server.ts - the renderer @taujs/server calls internally:
import { createRenderer, escapeHtml } from "@taujs/vue";import App from "./App.vue";
export const { renderSSR, renderStream } = createRenderer({ appComponent: App, headContent: ({ data, meta }) => ` <title>${escapeHtml(meta?.title || "τjs - Composing systems, not just apps")}</title> <meta name="description" content="${escapeHtml( meta?.description || (data as { message?: string })?.message || "τjs - Composing systems, not just apps" )}"> `, enableDebug: process.env.NODE_ENV === "development",});entry-client.ts - hydration entry:
import { hydrateApp } from "@taujs/vue";import App from "./App.vue";
hydrateApp({ appComponent: App, rootElementId: "root", enableDebug: import.meta.env.DEV,});App.vue - the root receives location from the server and picks the page. Note the ssr route uses the fallback idiom while the streaming route awaits under <Suspense>:
<script setup lang="ts">import { computed } from "vue";import HomePage from "./HomePage.vue";import StreamingPage from "./StreamingPage.vue";
const props = defineProps<{ location?: string; routeContext?: unknown }>();
// The server passes `location`; on the client fall back to the current path so hydration matches.const path = computed( () => props.location ?? (typeof window !== "undefined" ? window.location.pathname : "/"));const isStreaming = computed(() => path.value.startsWith("/streaming"));</script>
<template> <Suspense v-if="isStreaming"> <template #default> <StreamingPage /> </template> <template #fallback> <p>Loading greeting…</p> </template> </Suspense> <HomePage v-else /></template>HomePage.vue and StreamingPage.vue are the two page components. HomePage.vue (the ssr route) uses the fallback idiom shown in Fallback rendering; StreamingPage.vue (the streaming route) uses the blocking Suspense idiom shown in Suspense. They are the exact snippets from those sections, one per route.
index.html - the template the server fills in (<!--ssr-head--> / <!--ssr-html-->):
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!--ssr-head--> </head> <body> <main id="root"><!--ssr-html--></main> </body></html>That is the complete request flow: the server matches a route, runs its data loader, calls renderSSR / renderStream, assembles the HTML with your head and the __INITIAL_DATA__ payload, and your entry-client.ts hydrates it. You never inject __INITIAL_DATA__ or manage the template yourself.
Standalone usage
Section titled “Standalone usage”Outside @taujs/server, createRenderer and hydrateApp are framework-agnostic. The server plumbing - Express, Hono, native Node HTTP, backpressure, CSP nonces, AbortSignal - is identical to @taujs/react; see the React reference for those framework-specific server walkthroughs. The only Vue-specific differences are:
appComponentis a Vue component (or render function), not a JSX element.- Data is consumed with the Vue composables and idioms above.
- Static SSR can return
teleports, which you splice into your template yourself.
Static SSR (no streaming), for edge runtimes or simple cases - renderSSR returns the head and app HTML (and any teleports); assemble the document and inject window.__INITIAL_DATA__ yourself:
import { createRenderer, escapeHtml } from "@taujs/vue";import App from "./App.vue";
const { renderSSR } = createRenderer<{ message: string }>({ appComponent: App, headContent: ({ data }) => `<title>${escapeHtml(data?.message ?? "App")}</title>`,});
const data = { message: "Hello World" };const { headContent, appHtml } = await renderSSR(data, "/");// Build: <head>${headContent}</head>, <main id="root">${appHtml}</main>,// a <script>window.__INITIAL_DATA__=…</script>, and the entry-client module tag.TypeScript support
Section titled “TypeScript support”The package is written in TypeScript with full type definitions included.
Typed data contracts
Section titled “Typed data contracts”Type the payload once and thread it through the renderer and the composables:
interface PageData { message: string; user: { id: string; name: string };}
const { renderSSR, renderStream } = createRenderer<PageData>({ appComponent: App, headContent: ({ data }) => `<title>${escapeHtml(data?.message ?? "App")}</title>`,});<script setup lang="ts">import { useSSRDataAsync } from "@taujs/vue";
interface PageData { message: string; user: { id: string; name: string };}
const data = await useSSRDataAsync<PageData>(); // data is PageData</script>Logger types
Section titled “Logger types”ServerLogs and LoggerLike describe the accepted logger shapes; createVueErrorHandler builds a default app.config.errorHandler that logs through them:
import type { ServerLogs } from "@taujs/vue";import { createVueErrorHandler } from "@taujs/vue";
const logger: ServerLogs = { info: (meta, message) => console.log(message, meta), warn: (meta, message) => console.warn(message, meta), error: (meta, message) => console.error(message, meta),};
// Inside setupApp, if you want the τjs default handler explicitly:const setupApp = (app) => { app.config.errorHandler = createVueErrorHandler(logger, true);};Best practices
Section titled “Best practices”- Prefer Suspense for data. Reach for
await useSSRDataAsync<T>()under<Suspense>first; fall back touseSSRData<T>()+v-ifonly when you specifically want a non-blocking render. - Streamed routes block on data. Always use the blocking Suspense idiom on
streamingroutes so the data lands in the streamed HTML, not just in__INITIAL_DATA__. - Head data:
metafor static,attr.headfor dynamic. In streaming mode the data snapshot is usually pending when the head is built - guard optionaldatafields. Static values belong inmeta; DYNAMIC head values belong in anattr.headloader, which the host resolves before the render and delivers asheadData(handleundefined- it degrades on deadline oroptionalfailure). - Share one
setupAppacross server and client. Keep it synchronous, DOM-free and idempotent so the identical function runs on both sides; seed Pinia/state from SSR data rather than adding a serialisation channel. - Match
@vue/server-renderertovue. Pin both to the same version. - Handle CSR fallback. Provide
onHydrationErrorso a hydration failure degrades gracefully; remember the CSR path emits no hydration callbacks by design. - Reserve teleports for
ssrroutes.<Teleport>targets outside the app root are only captured byrenderSSR. - Client-side updates after SSR are userland. For data that changes after the initial render, call your own
/api/*endpoints with a client library such as TanStack Query or SWR - τjs keeps SSR request-first and does not add a client data channel.
Related packages
Section titled “Related packages”@taujs/server- Fastify-based server with SSR orchestration, routing and data loading.@taujs/react- the React renderer; shares this package’s contract and streaming semantics.
License
Section titled “License”MIT