Vue references

Vite + Vue with createContentClient and the references client plugin.
App.vue
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { content } from './composables/useContent'

// Both shapes come straight from the Content instance, no hand-written view models.
//
// Without populate, reference fields stay branded path strings:
//   data.author       -> Ref<'authors'>
//   data.relatedPosts -> Ref<'posts'>[]
const listRaw = () => content.list('posts')
type RawPost = Awaited<ReturnType<typeof listRaw>>[number]

// With populate, the references plugin rewrites them to the target documents:
//   data.author       -> ContentListFile<AuthorsData> | null
//   data.relatedPosts -> ContentListFile<PostsData>[]
const listPopulated = () => content.list('posts', { populate: ['author', 'relatedPosts'] })
type PopulatedPost = Awaited<ReturnType<typeof listPopulated>>[number]

const populate = ref(true)
const pending = ref(true)
const error = ref<string | null>(null)
const posts = ref<Array<RawPost | PopulatedPost>>([])

async function load() {
  pending.value = true
  error.value = null
  try {
    posts.value = populate.value ? await listPopulated() : await listRaw()
  } catch (err) {
    error.value = (err as Error).message
    posts.value = []
  } finally {
    pending.value = false
  }
}

const callSnippet = computed(() =>
  populate.value ? "content.list('posts', { populate: ['author', 'relatedPosts'] })" : "content.list('posts')"
)

watch(populate, load, { immediate: true })
</script>

<template>
  <div class="page">
    <header class="hero">
      <h1>References</h1>
      <p>
        Browser client via <code>createContentClient</code> + <code>references/client</code>. Toggle populate to inline
        <code>author</code> (another source) and <code>relatedPosts</code> (a self-reference).
      </p>

      <div class="controls">
        <label class="toggle">
          <input
            v-model="populate"
            type="checkbox"
          />
          <span>populate references</span>
        </label>
        <button
          type="button"
          :disabled="pending"
          @click="load"
        >
          Reload
        </button>
      </div>

      <p class="call">
        <code>{{ callSnippet }}</code>
      </p>
    </header>

    <p
      v-if="error"
      class="error"
    >
      {{ error }}
    </p>
    <p v-else-if="pending">Loading…</p>

    <div
      v-else
      class="posts"
    >
      <article
        v-for="post in posts"
        :key="post.path"
        class="card"
      >
        <header class="card-head">
          <h2>{{ post.data.title }}</h2>
          <code class="path">{{ post.path }}</code>
        </header>

        <p
          v-if="post.data.excerpt"
          class="excerpt"
        >
          {{ post.data.excerpt }}
        </p>

        <div class="fields">
          <section class="field">
            <h3>author</h3>
            <!-- A stored reference is a string; a populated one is a document. -->
            <p
              v-if="typeof post.data.author === 'string'"
              class="raw"
            >
              <code>{{ post.data.author }}</code>
            </p>
            <div
              v-else-if="post.data.author"
              class="person"
            >
              <strong>{{ post.data.author.data.name }}</strong>
              <span
                v-if="post.data.author.data.role"
                class="role"
              >
                {{ post.data.author.data.role }}
              </span>
              <code class="muted">{{ post.data.author.path }}</code>
            </div>
            <p
              v-else
              class="empty"
            >
              unresolved
            </p>
          </section>

          <section class="field">
            <h3>relatedPosts</h3>
            <ul
              v-if="post.data.relatedPosts?.length"
              class="people"
            >
              <li
                v-for="related in post.data.relatedPosts"
                :key="typeof related === 'string' ? related : related.path"
                class="person"
              >
                <code v-if="typeof related === 'string'">{{ related }}</code>
                <template v-else>
                  <strong>{{ related.data.title }}</strong>
                  <code class="muted">{{ related.path }}</code>
                </template>
              </li>
            </ul>
            <p
              v-else
              class="empty"
            >
              none resolved
            </p>
          </section>
        </div>
      </article>
    </div>
  </div>
</template>

<style>
:root {
  font-family: system-ui, sans-serif;
  color-scheme: light dark;
}
body {
  margin: 0;
  background: canvas;
  color: canvasText;
}
.page {
  max-width: 960px;
  margin: 0 auto;
  padding: 2rem 1.5rem 4rem;
}
.hero {
  display: grid;
  gap: 0.75rem;
  margin-bottom: 1.75rem;
}
.hero h1 {
  margin: 0;
}
.hero > p {
  margin: 0;
  opacity: 0.85;
  max-width: 56ch;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem;
}
.toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  user-select: none;
}
.call {
  margin: 0;
  font-size: 0.9rem;
  opacity: 0.8;
}
button {
  width: fit-content;
  padding: 0.45rem 0.85rem;
  border-radius: 6px;
  border: 1px solid #8886;
  background: transparent;
  color: inherit;
  cursor: pointer;
}
button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.posts {
  display: grid;
  gap: 1rem;
}
.card {
  border: 1px solid #8884;
  border-radius: 12px;
  padding: 1.15rem 1.25rem 1.25rem;
  display: grid;
  gap: 0.9rem;
}
.card-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 1rem;
}
.card-head h2 {
  margin: 0;
  font-size: 1.2rem;
}
.path {
  font-size: 0.85rem;
  opacity: 0.7;
}
.excerpt {
  margin: 0;
  opacity: 0.8;
  line-height: 1.45;
}
.fields {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.85rem;
}
.field {
  border: 1px solid #8883;
  border-radius: 10px;
  padding: 0.85rem 0.95rem;
  display: grid;
  gap: 0.55rem;
  align-content: start;
}
.field h3 {
  margin: 0;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: 0.65;
}
.people {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.65rem;
}
.person {
  display: grid;
  gap: 0.15rem;
}
.person strong {
  font-weight: 600;
}
.role {
  font-size: 0.92rem;
  opacity: 0.75;
}
.muted {
  font-size: 0.8rem;
  opacity: 0.6;
}
.raw,
.empty {
  margin: 0;
  font-size: 0.92rem;
}
.empty {
  opacity: 0.6;
  font-style: italic;
}
.raw code {
  word-break: break-all;
}
.error {
  color: #e55;
}
code {
  font-size: 0.92em;
}
@media (max-width: 720px) {
  .fields {
    grid-template-columns: 1fr;
  }
}
</style>

Server Content uses references(); the browser uses createContentClient with comark-content/plugins/references/client. Toggle populate to compare stored path strings vs inlined documents.

Posts reference documents two ways: author points at the authors source, and relatedPosts is a self-reference array pointing back at posts.

Two references are intentionally broken to show that resolution never throws: a missing relatedPosts entry is dropped from the array, while a missing author becomes null.