Add a blog to Vue.js
Create posts through a server endpoint, then render published Cli Blog content in Vue.
A Vue single-page app should request content from a backend-for-frontend (BFF). The browser renders published responses; the BFF owns caching and all key use.
Design the boundary
| Location | Key | Responsibility |
|---|---|---|
| Vue bundle | None | Request /api/blog/* and render content |
| Public delivery route | Public key | List and retrieve published posts |
| Protected write route | Private key | Create drafts after your app authorizes the caller |
Public keys can be used for published reads in a browser, but a server proxy keeps configuration and caching centralized. Never put a private key in a VITE_* variable.
Install Markdown dependencies
Use markdown-it with raw HTML disabled, then sanitize the output:
npm install markdown-it dompurify
npm install --save-dev @types/markdown-itConfigure server secrets
Set these only in the BFF environment:
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_PRIVATE_KEY=<private-api-key>Use a narrowly scoped private key. API keys explains types, permissions, and rotation.
Add shared types and requests
Create src/lib/blog.ts:
export type PostSummary = {
id: string;
title: string;
slug: string;
excerpt: string | null;
published_at: string | null;
};
export type Post = PostSummary & {
body_markdown: string | null;
seo_title?: string | null;
seo_description?: string | null;
};
export type PostList = {
data: PostSummary[];
has_more: boolean;
next_cursor: string | null;
};
export async function listPosts(after?: string): Promise<PostList> {
const query = new URLSearchParams();
if (after) query.set("after", after);
const response = await fetch(`/api/blog/posts?${query}`);
if (!response.ok) throw new Error("Could not load posts");
return response.json();
}
export async function getPost(slug: string): Promise<Post> {
const response = await fetch(`/api/blog/posts/${encodeURIComponent(slug)}`);
if (!response.ok) throw new Error(response.status === 404 ? "Post not found" : "Could not load post");
return response.json();
}Implement published reads on the server
The BFF list handler should construct the upstream query itself:
const url = new URL("https://api.cli-blog.com/v1/posts");
url.searchParams.set("status", "published");
url.searchParams.set("locale", "en-US");
url.searchParams.set("fields", "summary,seo");
url.searchParams.set("include", "authors,tags");
url.searchParams.set("limit", "12");
if (after) url.searchParams.set("after", after);
return fetch(url, {
headers: { "x-api-key": process.env.CLI_BLOG_PUBLIC_KEY! },
});For /api/blog/posts/:slug, call GET /v1/posts/{slug} with locale=en-US, fields=summary,content,seo, and include=authors,categories,tags,media.
Field groups add named groups of post fields. Includes embed related resources in the same response. Lists usually need summary,seo; detail pages add content. See Posts.
Build the index view
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { listPosts, type PostSummary } from "@/lib/blog";
const posts = ref<PostSummary[]>([]);
const nextCursor = ref<string | null>(null);
const error = ref("");
const loading = ref(false);
async function load(after?: string) {
loading.value = true;
error.value = "";
try {
const page = await listPosts(after);
posts.value = after ? [...posts.value, ...page.data] : page.data;
nextCursor.value = page.has_more ? page.next_cursor : null;
} catch {
error.value = "Could not load posts.";
} finally {
loading.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<article v-for="post in posts" :key="post.id">
<RouterLink :to="`/blog/${post.slug}`"><h2>{{ post.title }}</h2></RouterLink>
<p v-if="post.excerpt">{{ post.excerpt }}</p>
</article>
<button v-if="nextCursor" type="button" :disabled="loading" @click="load(nextCursor)">
{{ loading ? "Loading…" : "Load more" }}
</button>
</template>Use cursor pagination for feeds and load-more interfaces. When sending next_cursor as after, preserve the locale, sort, filters, fields, and includes. The maximum limit is 100. Read Pagination before choosing numbered pages.
Add the slug route
Register Vue Router routes:
const routes = [
{ path: "/blog", component: () => import("./views/BlogIndex.vue") },
{ path: "/blog/:slug", component: () => import("./views/BlogPost.vue") },
];Load the post whenever the slug changes:
<script setup lang="ts">
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import { computed, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { getPost, type Post } from "@/lib/blog";
const route = useRoute();
const post = ref<Post>();
const error = ref("");
const md = new MarkdownIt({ html: false, linkify: true });
const bodyHtml = computed(() => DOMPurify.sanitize(md.render(post.value?.body_markdown ?? "")));
watch(() => route.params.slug, async (value) => {
try {
post.value = await getPost(String(value));
document.title = post.value.seo_title ?? post.value.title;
} catch (cause) {
error.value = cause instanceof Error ? cause.message : "Could not load post";
}
}, { immediate: true });
</script>
<template>
<p v-if="error" role="alert">{{ error }}</p>
<article v-else-if="post">
<h1>{{ post.title }}</h1>
<div class="prose" v-html="bodyHtml"></div>
</article>
</template>Raw HTML is disabled in markdown-it, and DOMPurify handles the generated HTML. Keep both safeguards if you add plugins. Never bind unsanitized API or Markdown output with v-html.
Add SEO and discovery
Use your head-management library to map seo_title, seo_description, canonical_url, robots values, and social fields from the seo group. A client-only SPA updates these after JavaScript loads; use Nuxt or another SSR setup when server-rendered metadata is required.
Proxy GET /v1/sitemap and GET /v1/feed through server routes and return their XML content types. Link the sitemap from robots.txt and the feed from the document head.
Create a draft from trusted code
After your BFF verifies the session, role, CSRF token, and payload, create a draft:
await fetch("https://api.cli-blog.com/v1/posts", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.CLI_BLOG_PRIVATE_KEY!,
},
body: JSON.stringify({
title: input.title,
body_markdown: input.markdown,
locale: "en-US",
status: "draft",
}),
});Do not expose a generic write proxy. Allow only the fields your workflow needs, and keep publication as a separate reviewed action.
Cache and revalidate
Cache successful published responses at the BFF or CDN with a short shared TTL and stale revalidation. Purge the index and affected slug after publishing. Do not cache authorization failures or temporary upstream failures as successful empty content.
Production checklist
- Keep private keys out of
VITE_*, bundles, logs, and browser error payloads. - Use
summary,seofor list cards and addcontentonly for detail pages. - Disable Markdown HTML, sanitize output, and review external link behavior.
- Use cursor pagination and deduplicate entries when appending pages.
- Show loading, empty, error, retry, and not-found states.
- Render SEO on the server when reliable crawler previews matter.
- Revalidate caches after publish, schedule, archive, or slug changes.
- Verify sitemap and feed routes in the deployed environment.