Primate

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 components in components using Single File Component syntax.

<!-- components/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";

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

route.get(() => response.view("User.vue", {
  user: { name: "John", role: "Developer" },
  permissions: ["read", "write"],
}));

Access the props in the component:

<!-- components/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>

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 validate from "@primate/vue/validate";

interface Props {
  id: string;
  counter: number
}

const props = defineProps<Props>();
const counter = validate<number>(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 number from "pema/number";
import string from "pema/string";

await Counter.schema.create();

route.get(async () => {
  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
  });
});

route.post(async request => {
  const id = string.parse(request.query.get("id"));
  const body = request.body.json(number.coerce);
  await Counter.update({ id }, { counter: body });
  return null;
});

The wrapper automatically tracks loading states, captures validation errors, and posts updates on state changes.

Forms

Create forms with Vue's Composition API and reactive data.

<script lang="ts" setup>
import { ref, computed } from "vue";

const email = ref("");
const password = ref("");
const errors = ref<{email?: string; password?: string}>({});

const isFormValid = computed(() => email.value && password.value);

const validateForm = () => {
  errors.value = {};

  if (!email.value) {
    errors.value.email = "Email is required";
  } else if (!/\S+@\S+\.\S+/.test(email.value)) {
    errors.value.email = "Email must be valid";
  }

  if (!password.value) {
    errors.value.password = "Password is required";
  } else if (password.value.length < 8) {
    errors.value.password = "Password must be at least 8 characters";
  }

  return Object.keys(errors.value).length === 0;
};

const handleSubmit = async (e: Event) => {
  e.preventDefault();

  if (!validateForm()) return;

  await fetch("/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      email: email.value,
      password: password.value
    }),
  });
};
</script>

<template>
  <form @submit="handleSubmit" style="max-width: 400px; margin: 2rem auto;">
    <h2>Login</h2>

    <div style="margin-bottom: 1rem;">
      <input type="email" placeholder="Email" v-model="email" />
      <p v-if="errors.email">{{ errors.email }}</p>
    </div>

    <div style="margin-bottom: 1rem;">
      <input type="password" placeholder="Password" v-model="password" />
      <p v-if="errors.password">{{ errors.password }}</p>
    </div>

    <button
      type="submit"
      :disabled="!isFormValid"
      :style="{
        backgroundColor: isFormValid ? '#007bff' : '#ccc',
        cursor: isFormValid ? 'pointer' : 'not-allowed'
      }"
    >
      Submit
    </button>
  </form>
</template>

Add the corresponding route:

// routes/login.ts
import route from "primate/route";
import response from "primate/response";
import pema from "pema";
import string from "pema/string";

const LoginSchema = pema({
  email: string.email(),
  password: string.min(8),
});

route.get(() => response.view("LoginForm.vue"));

route.post(async request => {
  const body = await request.body.json(LoginSchema);

  // implement authentication logic

  return null;
});

Layouts

Create layout components that wrap your pages using <slot>.

Create a layout component:

<!-- components/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";

route.get(() => response.view("Layout.vue", { brand: "Primate Vue Demo" }));

Pages under this route subtree render inside the layout's <slot>.

Internationalization

Primate's t function is framework-agnostic. In Vue, call it directly:

<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
fileExtensions string[] [".vue"] Associated file extensions
ssr boolean true Active server-side rendering
spa boolean true Active client-browsing

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

Resources

Previous
Voby
Next
Web Components