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.