$ cli-blog docs
Examples & Guides

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

LocationKeyResponsibility
Vue bundleNoneRequest /api/blog/* and render content
Public delivery routePublic keyList and retrieve published posts
Protected write routePrivate keyCreate 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-it

Configure 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,seo for list cards and add content only 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.

On this page