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
| Location | Key | Responsibility |
|---|---|---|
| Vue page or component | None | Render local /api/blog/* responses |
| Nitro delivery route | Public key | Read published posts and XML |
| Protected Nitro route | Private key | Create 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-htmlConfigure 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,seofor lists andcontentonly 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
429and5xxresponses without leaking keys into telemetry.