$ cli-blog docs
Examples & Guides

Add a blog to Nuxt.js

Create drafts and read published posts in Nuxt with server routes and runtime config.

Nuxt’s server routes provide a clean boundary for delivery reads, caching, and trusted writes. Pages call local routes; Nitro calls Cli Blog.

Plan the architecture

LocationKeyResponsibility
Vue page or componentNoneRender local /api/blog/* responses
Nitro delivery routePublic keyRead published posts and XML
Protected Nitro routePrivate keyCreate drafts after application authorization

Keep both keys in private runtime config. Do not place a private key under runtimeConfig.public.

Install Markdown dependencies

Parse and sanitize Markdown in the server detail route:

npm install marked sanitize-html
npm install --save-dev @types/sanitize-html

Configure runtime secrets

Add local values to .env and production values to the deployment secret manager:

NUXT_CLI_BLOG_PUBLIC_KEY=<public-api-key>
NUXT_CLI_BLOG_PRIVATE_KEY=<private-api-key>

Map them in nuxt.config.ts:

export default defineNuxtConfig({
  runtimeConfig: {
    cliBlogPublicKey: "",
    cliBlogPrivateKey: "",
  },
});

Nuxt automatically maps the NUXT_ variables at runtime. Read API keys for permission and rotation guidance.

Create the published list route

Add server/api/blog/posts/index.get.ts:

export default defineEventHandler(async (event) => {
  const { after } = getQuery(event);
  const config = useRuntimeConfig(event);

  return $fetch("https://api.cli-blog.com/v1/posts", {
    headers: { "x-api-key": config.cliBlogPublicKey },
    query: {
      status: "published",
      locale: "en-US",
      fields: "summary,seo",
      include: "authors,tags",
      limit: 12,
      after: typeof after === "string" ? after : undefined,
    },
  });
});

summary adds list-card fields, seo adds metadata fields, and the includes embed authors and tags. See Posts for every field group and include.

Build the blog index page

Add pages/blog/index.vue:

<script setup lang="ts">
type PostSummary = { id: string; title: string; slug: string; excerpt: string | null };
type PostList = { data: PostSummary[]; has_more: boolean; next_cursor: string | null };

const { data, error, status } = await useFetch<PostList>("/api/blog/posts");
const posts = ref(data.value?.data ?? []);
const nextCursor = ref(data.value?.next_cursor ?? null);

async function loadMore() {
  if (!nextCursor.value) return;
  const page = await $fetch<PostList>("/api/blog/posts", {
    query: { after: nextCursor.value },
  });
  posts.value.push(...page.data);
  nextCursor.value = page.has_more ? page.next_cursor : null;
}
</script>

<template>
  <p v-if="status === 'pending'">Loading posts…</p>
  <p v-else-if="error" role="alert">Could not load posts.</p>
  <div v-else>
    <article v-for="post in posts" :key="post.id">
      <NuxtLink :to="`/blog/${post.slug}`"><h2>{{ post.title }}</h2></NuxtLink>
      <p v-if="post.excerpt">{{ post.excerpt }}</p>
    </article>
    <button v-if="nextCursor" type="button" @click="loadMore">Load more</button>
  </div>
</template>

Cursor pagination is recommended for load-more interfaces. Keep locale, filters, sort, fields, and includes unchanged when sending next_cursor as after. The maximum limit is 100. See Pagination.

Create the post detail route

Add server/api/blog/posts/[slug].get.ts:

import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

type Post = {
  id: string;
  title: string;
  slug: string;
  excerpt: string | null;
  body_markdown: string | null;
  seo_title?: string | null;
  seo_description?: string | null;
  canonical_url?: string | null;
  robots_index?: boolean;
};

export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, "slug");
  if (!slug) throw createError({ statusCode: 400, statusMessage: "Missing slug" });

  const config = useRuntimeConfig(event);
  const post = await $fetch<Post>(
    `https://api.cli-blog.com/v1/posts/${encodeURIComponent(slug)}`,
    {
      headers: { "x-api-key": config.cliBlogPublicKey },
      query: {
        locale: "en-US",
        fields: "summary,content,seo",
        include: "authors,categories,tags,media",
      },
    },
  );

  const parsed = await marked.parse(post.body_markdown ?? "");
  return { ...post, body_html: sanitizeHtml(parsed) };
});

Sanitize after parsing, even though the content came from your organization. If you customize allowed tags, restrict URL protocols and review image, iframe, and link behavior.

Render the slug page

Add pages/blog/[slug].vue:

<script setup lang="ts">
const route = useRoute();
const { data: post, error } = await useFetch(
  () => `/api/blog/posts/${encodeURIComponent(String(route.params.slug))}`,
);

if (error.value?.statusCode === 404) {
  throw createError({ statusCode: 404, statusMessage: "Post not found" });
}

useSeoMeta({
  title: () => post.value?.seo_title ?? post.value?.title,
  description: () => post.value?.seo_description ?? post.value?.excerpt,
  robots: () => post.value?.robots_index === false ? "noindex, nofollow" : "index, follow",
});

useHead(() => ({
  link: post.value?.canonical_url
    ? [{ rel: "canonical", href: post.value.canonical_url }]
    : [],
}));
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <div class="prose" v-html="post.body_html"></div>
  </article>
</template>

The HTML is safe to bind because the Nitro route sanitizes it. Do not return unsanitized parsed Markdown to this template.

Add sitemap and feed routes

Create server/routes/sitemap.xml.get.ts and use the same pattern for feed.xml:

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event);
  const response = await fetch("https://api.cli-blog.com/v1/sitemap?locale=en-US&limit=100", {
    headers: { "x-api-key": config.cliBlogPublicKey },
  });
  if (!response.ok) throw createError({ statusCode: response.status });

  return new Response(await response.text(), {
    headers: { "content-type": "application/xml; charset=utf-8" },
  });
});

Use /v1/feed for the feed route and return application/rss+xml; charset=utf-8. Link the sitemap in robots.txt and the feed in useHead.

Create a protected draft route

Add your existing session, organization-role, CSRF, validation, and rate-limit checks before this call:

export default defineEventHandler(async (event) => {
  const input = await readBody<{ title: string; markdown: string }>(event);
  const config = useRuntimeConfig(event);

  return $fetch("https://api.cli-blog.com/v1/posts", {
    method: "POST",
    headers: { "x-api-key": config.cliBlogPrivateKey },
    body: {
      title: input.title,
      body_markdown: input.markdown,
      locale: "en-US",
      status: "draft",
    },
  });
});

Do not expose this route until those application checks exist. Accept only the fields the workflow needs, and keep publication as a separate reviewed action.

Cache and revalidate

Use Nitro route rules for published reads:

export default defineNuxtConfig({
  routeRules: {
    "/api/blog/posts": { swr: 60 },
    "/api/blog/posts/**": { swr: 300 },
  },
});

Revalidate the list and affected slug after publication. Do not cache private write routes, 401, 403, 429, or temporary 5xx failures as successful content.

Production checklist

  • Keep private keys outside runtimeConfig.public, payloads, logs, and client bundles.
  • Request summary,seo for lists and content only on detail pages.
  • Sanitize parsed Markdown before returning HTML.
  • Preserve the full cursor query and handle loading, empty, error, and 404 states.
  • Render metadata and canonical links in the server response.
  • Revalidate list and detail caches after editorial state changes.
  • Verify sitemap, feed, and robots output at the deployed origin.
  • Monitor upstream 429 and 5xx responses without leaking keys into telemetry.

On this page