Skip to content

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

@taujs/vue provides:

  • Streaming SSR with renderToSimpleStream from @vue/server-renderer
  • Static SSR with renderToString for simpler use cases and <Teleport> support
  • Automatic hydration with built-in data synchronisation and CSR fallback
  • Vue-native data store using reactive refs, provide/inject and <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.



Terminal window
npm install @taujs/vue vue @vue/server-renderer

Recommended dependency ranges:

{
"dependencies": {
"@taujs/vue": "latest",
"@vue/server-renderer": "^3.5.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0"
}
}

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;
};

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 },
});

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

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.

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.

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.


A streaming route may declare attr.deferred alongside attr.data - named, route-owned work whose value is allowed to arrive after rendering begins (see Route configuration). τjs starts each named loader once per request, outside the component tree, and @taujs/vue projects the named promise onto Vue’s own async setup() + <Suspense> model. The component starts nothing and refetches nothing.

<script setup lang="ts">
import { createDeferredAccessor } from "@taujs/vue";
// `export type ProductDeferredData = DeferredDataOf<typeof productRoute>` beside the route:
// a type-only import, so the client bundle pulls in no server code.
import type { ProductDeferredData } from "../server/routes/product";
// Typed from the route config alone - the payload shape is never re-declared here.
const deferred = createDeferredAccessor<ProductDeferredData>();
// Called synchronously in setup(), before the first await: the accessor injects.
const outcome = await deferred.result("reviews");
</script>
<template>
<p v-if="outcome.status === 'complete'">{{ outcome.value.count }} reviews</p>
<p v-else-if="outcome.status === 'failed'">Reviews are unavailable.</p>
<p v-else>Reviews did not arrive in time.</p>
</template>

createDeferredAccessor<D>() returns two reads, and both must be called synchronously in setup() before the first await:

  • deferred.result(key) returns Promise<DeferredResult<T>> - a promise that never rejects for a declared key, resolving to { status: 'complete', value }, { status: 'failed' } or { status: 'aborted' }. This is the primary Vue read: a handled failure branch completes the response, and server and client render the identical branch from the identical detail-free outcome.
  • deferred.data(key) returns Promise<T> and rejects with a DeferredDataError (carrying key and outcome) when the value never arrived. The rejection follows Vue’s native error channel (onErrorCaptured, app.config.errorHandler). During streamed SSR this can leave the response incomplete. Vue renders each server subtree exactly once, so a boundary that catches the rejection has already emitted its markup: it cannot substitute fallback UI into the response, and a rejection or deadline after the head has been emitted aborts the transfer. At a deferred deadline, window.__INITIAL_DATA__, the deferred envelope, the bootstrap script and the terminal event are all absent, so the page cannot hydrate. Use deferred.result(key) for any entry that can fail.

The untyped forms useDeferredData(key) and useDeferredDataResult(key) are exported for cases where no route type is to hand; prefer the typed façade.

<template>
<!-- Everything independent of the deferred value comes FIRST. -->
<SiteHeader />
<ProductSummary />
<!-- The deferred boundary comes LAST: nothing behind it is stalled. -->
<Suspense>
<DeferredReviews />
<template #fallback><p>Loading reviews…</p></template>
</Suspense>
</template>

Every declared entry settles as exactly one of complete, failed or aborted, recorded once on the request episode. Two points worth knowing when you read the outcomes:

  • An unconsumed entry whose loader rejects after the response terminal reads as aborted, not failed - the response had already finished, so nothing was waiting on the value.
  • complete means deliverable. A value that resolves but cannot cross the hydration boundary is classified failed uniformly across the server render, the episode and hydration, so all three always agree.

Outcomes for the boundaries a component actually read are seeded into hydration from a private internal carrier written beside window.__INITIAL_DATA__; every seeded entry is already settled, so hydration re-executes no loader and issues no refetch. Under hydrate: false no carrier is emitted and the value still streams into the server-rendered HTML.


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 - appComponent receives routeContext as R, headContent receives data: T and headData?: 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 optional data fields, lean on meta/routeContext, or declare attr.head on the route so the host resolves DYNAMIC head data before the render and passes it as headData (typed by createRenderer’s third generic H). 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. Vue’s head stays SINGLE-BUILD, pre-render; headData widens what that one build can see. Escape every headData/data-derived value with escapeHtml.
  • streamOptions.shellTimeoutMs: Time-to-first-content watchdog in ms (default 10000). See Streaming semantics.
  • streamOptions.deferredTimeoutMs: The one response-level completion deadline for deferred route data. Default 15000. The general derivation is min(15000, fatal backstop / 2), but @taujs/vue declares no finite post-shell fatal backstop for it to halve - shellTimeoutMs bounds the pre-shell phase only and is stopped before this deadline is armed - so the Vue default is the flat cap. It is measured from renderStream entry and armed at shell commit (the first streamed chunk) with whatever remains of that budget; a budget already spent arms at 1ms rather than never. It is never reset per key and it latches: a boundary whose first read arrives after expiry is born aborted rather than starting a fresh unbounded wait. Vue streams strictly in order and has no client-render instruction, so the Vue spelling of “abandon the boundary” is to settle every still-pending consumed read as aborted: the application’s own aborted branch renders into the response, the stream resumes and the document terminates normally. The stream controller is never touched, so an abandoned boundary is a normal completion, not a stream failure. The value must be a positive finite number of milliseconds - there is no 0/Infinity disable sentinel, because this deadline is what keeps total response time bounded - and any other value throws a TypeError from createRenderer. It is factory-only: unlike shellTimeoutMs it has no per-call form.
  • logger: Custom logger implementation (LoggerLike).
  • enableDebug: Enable detailed logging (default false).
  • setupApp: Configure the per-request App instance (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

entry-server.ts
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;
/**
* The one response-level deferred-data completion deadline, in ms (default 15000).
* Positive finite only, validated by createRenderer; factory-only, with no per-call form.
*/
deferredTimeoutMs?: number;
};

@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.

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
);

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"

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;

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:

  • shellTimeoutMs is 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 to onError.
  • 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 alias onFinish(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 to createRenderer).
  • routeContext: Per-request route context forwarded into appComponent and headContent.
  • renderStream additionally accepts shellTimeoutMs here to override the renderer default per call. deferredTimeoutMs is deliberately absent from the per-call options: the deferred deadline is a boot-validated bound on the whole response, so it is configured once on createRenderer and never per request.

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.


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:

entry-client.ts
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 with createSSRApp(...).mount(rootEl).
  • If window[dataKey] is missing: clears any stale markup and mounts a fresh CSR app. No hydration beacon events and no onStart fire - a CSR mount is not a hydration. A successful CSR mount still calls onSuccess(app) (the committed root); a failed one calls onHydrationError.
  • Automatically handles DOMContentLoaded timing.
  • Wraps your app in SSRStoreProvider on 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. Exactly one of onSuccess or onHydrationError settles per call: once a hydration has succeeded, a later Vue error is an ordinary logged error and cannot reverse that success into a failure.

Logging. Hydration debug logging (under enableDebug) contains lifecycle messages only - the route-data payload and the store object are never logged, so a logger that forwards to a server sink cannot disclose request data.

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 no hydration beacons (it still calls onSuccess(app) on a committed root).

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>

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.

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" } | undefined

Must be called within an SSRStoreProvider - it throws "useSSRStore must be used within a SSRStoreProvider" otherwise.

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.

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() returns the store’s ready promise (Promise<void>), for awaiting in async setup without needing the value.
  • useSSRStatus() returns ComputedRef<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 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)).


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;
vite.config.ts
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.


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 App is 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 */
});
};
// Server
const { renderSSR } = createRenderer({ appComponent: App, headContent, setupApp });
// Client - the SAME function
hydrateApp({ 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-client
import { 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.


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.


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.


τjs governs the response for a URL. What a subsequent navigation does is renderer-independent: a navigation between τjs apps is a full document load and a new governed response, while within one application the app’s own client router (Vue Router or a custom router) owns the transition and any data it needs. See Navigation boundaries for the model, and App Shell Architecture when one shell composes several sections client-side.

For data that changes after the first render, fetch it from your own /api/* endpoints with a client library such as TanStack Query or SWR. τjs keeps SSR request-first and adds no generic client data channel; the initial snapshot stays the authority for the response it was built for.


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.


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:

  • appComponent is 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.

The package is written in TypeScript with full type definitions included.

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>

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);
};

  1. Prefer Suspense for data. Reach for await useSSRDataAsync<T>() under <Suspense> first; fall back to useSSRData<T>() + v-if only when you specifically want a non-blocking render.
  2. Streamed routes block on data. Always use the blocking Suspense idiom on streaming routes so the data lands in the streamed HTML, not just in __INITIAL_DATA__.
  3. Head data: meta for static, attr.head for dynamic. In streaming mode the data snapshot is usually pending when the head is built - guard optional data fields. Static values belong in meta; DYNAMIC head values belong in an attr.head loader, which the host resolves before the render and delivers as headData (handle undefined - it degrades on deadline or optional failure).
  4. Share one setupApp across 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.
  5. Match @vue/server-renderer to vue. Pin both to the same version.
  6. Handle CSR fallback. Provide onHydrationError so a hydration failure degrades gracefully; the CSR path emits no hydration beacons or onStart, but a successful CSR mount does call onSuccess(app).
  7. Reserve teleports for ssr routes. <Teleport> targets outside the app root are only captured by renderSSR.
  8. 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.

  • @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.

MIT