Vue
Primate runs Vue with server-side rendering, hydration, client navigation, layouts, validation and i18n.
Setup
Install
npm install @primate/vue vue
Configure
import config from "primate/config";
import vue from "@primate/vue";
export default config({
modules: [
vue(),
],
});
Components
Create Vue SFC components in views.
<!-- views/PostIndex.vue -->
<script lang="ts" setup>
interface Post {
title: string;
excerpt?: string;
}
interface Props {
title: string;
posts: Post[];
}
const props = defineProps<Props>();
</script>
<template>
<div>
<h1>{{ title }}</h1>
<article>
<div v-for="post in posts" :key="post.title">
<h2>{{ post.title }}</h2>
<p v-if="post.excerpt">{{ post.excerpt }}</p>
</div>
</article>
</div>
</template>Serve the component from a route:
// routes/posts.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
const posts = [
{ title: "First Post", excerpt: "Introduction to Primate with Vue" },
{ title: "Second Post", excerpt: "Building reactive applications" },
];
return response.view("PostIndex.vue", { title: "Blog", posts });
},
});
Props
Props passed to response.view map directly to component props.
Pass props from a route:
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("User.vue", {
user: { name: "John", role: "Developer" },
permissions: ["read", "write"],
});
},
});Access the props in the component:
<!-- views/User.vue -->
<script lang="ts" setup>
interface User {
name: string;
role: string;
}
interface Props {
user: User;
permissions: string[];
}
const props = defineProps<Props>();
</script>
<template>
<div>
<h2>{{ user.name }}</h2>
<p>Role: {{ user.role }}</p>
<ul>
<li v-for="permission in permissions" :key="permission">
{{ permission }}
</li>
</ul>
</div>
</template>
Request
Import the useRequest composable from app:vue to access the current request
inside any component. The composable updates automatically on client-side
navigation.
<script lang="ts" setup>
import { useRequest } from "app:vue";
const request = useRequest();
</script>
<template>
<p>Current path: {{ request.url.pathname }}</p>
</template>The useRequest composable returns a RequestPublic object.
| Property | Type | Description |
|---|---|---|
url |
URL |
current request URL |
query |
Dict<string> |
query string parameters |
headers |
Dict<string> |
request headers |
cookies |
Dict<string> |
request cookies |
Reactivity with Composition API
Vue's Composition API provides reactive state management with ref and
computed.
<script lang="ts" setup>
import { ref, computed } from "vue";
const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>
<template>
<div>
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
<p>Doubled: {{ doubled }}</p>
</div>
</template>
Validation
Use Primate's validated state wrapper to synchronize with backend routes.
<script lang="ts" setup>
import { computed } from "vue";
import client from "@primate/vue/client";
interface Props {
id: string;
counter: number;
}
const props = defineProps<Props>();
const counter = client.field(props.counter).post(`/counter?id=${props.id}`);
const loading = computed(() => counter.loading.value);
const error = computed(() => counter.error.value?.message);
</script>
<template>
<div style="margin-top: 2rem; text-align: center;">
<h2>Counter Example</h2>
<div>
<button @click="counter.update(n => n - 1)" :disabled="loading">
-
</button>
<span style="margin: 0 1rem;">{{ counter.value }}</span>
<button @click="counter.update(n => n + 1)" :disabled="loading">
+
</button>
</div>
<p v-if="counter.error" style="color: red; margin-top: 1rem;">
{{ error }}
</p>
</div>
</template>Add corresponding backend validation in the route:
// routes/counter.ts
import Counter from "#store/Counter";
import route from "primate/route";
import response from "primate/response";
import p from "pema";
await Counter.create();
export default route({
async get() {
const counters = await Counter.find({});
const counter = counters.length === 0
? await Counter.insert({ counter: 10 })
: counters[0];
return response.view("Counter.vue", {
id: counter.id,
counter: counter.counter,
});
},
async post(request) {
const id = p.string.parse(request.query.get("id"));
const body = p.loose.number.parse(await request.body.json());
await Counter.update(id, { set: { counter: body } });
return null;
},
});The wrapper automatically tracks loading states, captures validation errors, and posts updates on state changes.
Forms
Use client.form from @primate/vue/client to wire forms to backend routes
with automatic field-level validation and error display.
<!-- views/LoginForm.vue -->
<script lang="ts" setup>
import client from "@primate/vue/client";
const form = client.form({ initial: { email: "", password: "" } });
</script>
<template>
<form
method="post"
action="/login"
:id="form.id"
@submit="form.submit"
>
<p v-if="form.errors.length" style="color: red;">{{ form.errors[0] }}</p>
<div style="margin-bottom: 1rem;">
<input
type="email"
:name="form.field('email').name"
:value="form.field('email').value"
placeholder="Email"
/>
<p v-if="form.field('email').error" style="color: red;">
{{ form.field('email').error }}
</p>
</div>
<div style="margin-bottom: 1rem;">
<input
type="password"
:name="form.field('password').name"
:value="form.field('password').value"
placeholder="Password"
/>
<p v-if="form.field('password').error" style="color: red;">
{{ form.field('password').error }}
</p>
</div>
<button type="submit" :disabled="form.submitting">
{{ form.submitting ? "Submitting..." : "Submit" }}
</button>
</form>
</template>Add the corresponding route:
// routes/login.ts
import route from "primate/route";
import response from "primate/response";
import p from "pema";
const LoginSchema = p({
email: p.string.email(),
password: p.string.min(8),
});
export default route({
get() {
return response.view("LoginForm.vue");
},
async post(request) {
const body = LoginSchema.parse(await request.body.form());
// implement authentication logic
return null;
},
});Validation errors from the server are automatically surfaced per-field via
form.field(name).error. The form.submitting flag disables the submit
button while the request is in flight.
Form API
| Property | Type | Description |
|---|---|---|
form.id |
string |
Unique form ID for the id attr |
form.submit |
(event?) => Promise |
Submit handler for @submit |
form.submitting |
boolean |
True while the request is in flight |
form.errors |
string[] |
Form-level errors |
form.field(name) |
Field |
Access a named field |
Field API
| Property | Type | Description |
|---|---|---|
field.name |
string |
Field name for the name attr |
field.value |
T |
Initial field value |
field.error |
string|null |
First validation error or null |
field.errors |
string[] |
All validation errors for field |
Layouts
Create layout components that wrap your pages using <slot>.
<!-- views/Layout.vue -->
<script lang="ts" setup>
interface Props {
brand?: string;
}
const props = withDefaults(defineProps<Props>(), {
brand: "My App",
});
</script>
<template>
<div>
<header>
<nav style="padding: 1rem; background-color: #f8f9fa;">
<h1>{{ brand }}</h1>
<a href="/" style="margin-right: 1rem;">Home</a>
<a href="/about" style="margin-right: 1rem;">About</a>
</nav>
</header>
<main style="padding: 2rem;">
<slot />
</main>
<footer style="padding: 1rem; background-color: #f8f9fa; text-align: center;">
© 1996 {{ brand }}
</footer>
</div>
</template>Next, register the layout via a +layout.ts file:
// routes/+layout.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Layout.vue", { brand: "Primate Vue Demo" });
},
});Pages under this route subtree render inside the layout's <slot>.
Internationalization
Create an i18n bridge file that adapts Primate's headless translator to Vue's reactivity model:
// lib/i18n.ts
import app from "#app";
import i18n from "@primate/vue/i18n";
export default i18n(app.i18n);Import and use the bridged translator directly in views:
<script lang="ts" setup>
import t from "#i18n";
</script>
<template>
<div>
<h1>{{ t("welcome") }}</h1>
<button @click="t.locale.set('en-US')">{{ t("english") }}</button>
<button @click="t.locale.set('de-DE')">{{ t("german") }}</button>
<p>{{ t("current_locale") }}: {{ t.locale.get() }}</p>
</div>
</template>Primate's integration automatically subscribes to locale changes and triggers rerenders when switching languages.
Head Tags
Use Vue's onMounted to manage document head elements.
<script lang="ts" setup>
import { onMounted } from "vue";
onMounted(() => {
document.title = "About Us - Primate Vue Demo";
const metaDescription = document.querySelector('meta[name="description"]');
if (metaDescription) {
metaDescription.setAttribute("content", "Learn more about our company");
} else {
const meta = document.createElement("meta");
meta.name = "description";
meta.content = "Learn more about our company";
document.head.appendChild(meta);
}
});
</script>
<template>
<div style="max-width: 800px; margin: 2rem auto; padding: 0 1rem;">
<h1>About Us</h1>
<p>
Welcome to our Primate Vue demo application. This page demonstrates
how to manage document head elements including the title and meta tags
for better SEO and social media sharing.
</p>
</div>
</template>
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| extensions | string[] |
[".vue"] |
Associated file extensions |
| ssr | boolean |
true |
Enable server-side rendering |
| csr | boolean |
true |
Enable client-side rendering |
Example
import vue from "@primate/vue";
import config from "primate/config";
export default config({
modules: [
vue({
// add `.component.vue` to associated file extensions
extensions: [".vue", ".component.vue"],
}),
],
});