Developer documentation
Fetch content in Nitro server routes and render it with Vue.
Nuxt keeps private configuration on the server through runtimeConfig, and gives you a cache you can invalidate from a webhook. Your pages talk to your own server routes, so the API key stays behind them.
A complete blog: cached CMS reads with tag-style invalidation, a publish webhook, category archives, pagination, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap and robots.txt. Nuxt 4, Tailwind CSS v4, MIT licensed.
npx degit contioreach/nuxtjs-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env
# add your API key to .env, then:
npm run devThe rest of this page builds the same integration from scratch, so you can add ContioReach to an application you already have. The code matches the starter, so you can read either and recognise the other.
Declare every environment-dependent value in runtimeConfig. Anything outside the public key stays on the server and is never serialized into the payload the browser receives.
export default defineNuxtConfig({
runtimeConfig: {
// Server-only.
cmsApiUrl: process.env.CMS_API_URL,
cmsApiKey: process.env.CMS_API_KEY,
revalidationSecret: process.env.REVALIDATION_SECRET,
public: {
// Anything here IS sent to the browser. No credentials.
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
}
}
});CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret
NUXT_PUBLIC_SITE_URL=http://localhost:3000Reading process.env explicitly, as above, is why the private variables need no NUXT_ prefix. Nuxt can also map them by convention — NUXT_CMS_API_KEY onto cmsApiKey — but naming them in the config makes the whole surface visible in one file.
Never put the API key under runtimeConfig.public. Everything under public is serialized into the page for the client to read.
A blank API key produces 401s from the CMS and an empty blog, which is a confusing way to find out. One small helper turns that into a named error:
function required(name, value) {
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. See .env.example.`
);
}
return value;
}
export function cmsConfig() {
const runtime = useRuntimeConfig();
return {
baseUrl: required("CMS_API_URL", runtime.cmsApiUrl),
apiKey: required("CMS_API_KEY", runtime.cmsApiKey)
};
}
export function revalidationSecret() {
return required("REVALIDATION_SECRET", useRuntimeConfig().revalidationSecret);
}
export function apiHeaders() {
return {
"Content-Type": "application/json",
"X-API-Key": cmsConfig().apiKey
};
}Files in server/utils are auto-imported into any server route. Nitro's $fetch is uncached by default, so caching is explicit: each reader is wrapped in defineCachedFunction under a group that doubles as a cache tag.
async function apiRequest(endpoint) {
const { baseUrl } = cmsConfig();
const data = await $fetch(`${baseUrl}${endpoint}`, {
headers: apiHeaders(),
// Surface the CMS's own status instead of a generic 500.
onResponseError({ response }) {
throw createError({
statusCode: response.status,
statusMessage: `CMS request failed: ${response.status}`
});
}
});
// A response can arrive with HTTP 200 and success: false.
if (!data?.success) {
throw createError({
statusCode: 502,
statusMessage: data?.error?.message || "CMS request failed"
});
}
return data;
}
function query(params) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
search.append(key, Array.isArray(value) ? value.join(",") : String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
/* One wrapper so every reader gets the same window and the same
stale-while-revalidate behaviour: once the hour lapses the stale value is
served and the refresh happens behind it, so no visitor waits on the CMS. */
function cachedReader(group, name, fn) {
return defineCachedFunction(fn, {
group,
name,
maxAge: 60 * 60,
swr: true,
getKey: (params = {}) => JSON.stringify(params) || "default"
});
}
export const getBlogs = cachedReader("blogs", "list", (params = {}) =>
apiRequest(
"/v1/blogs" +
query({
page: params.page,
limit: params.limit,
category: params.category,
tags: params.tags,
search: params.search,
minimal: params.minimal
})
)
);
// minimal=false so the detail page gets the full HTML body.
export const getBlogBySlug = cachedReader("blogs", "by-slug", (slug) =>
apiRequest("/v1/blogs" + query({ slug, minimal: "false" }))
);
export const getCategories = cachedReader("categories", "list", (params = {}) =>
apiRequest("/v1/categories" + query({ limit: params.limit ?? 100 }))
);The group is the important part. defineCachedFunction stores each entry under a key namespaced with its group, so the publish webhook in step 5 can drop exactly the reads affected by a change and leave the rest warm.
Two thin routes expose the content to your pages. These run only on the server, so the key stays put.
export default defineEventHandler(async (event) => {
const { page, category, search } = getQuery(event);
const result = await getBlogs({
page: Number(page) || 1,
limit: 12,
category,
search,
minimal: "true"
});
return { posts: result.data, meta: result.meta };
});export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
const result = await getBlogBySlug(slug);
// A slug lookup returns one object, not an array.
const post = Array.isArray(result.data) ? result.data[0] : result.data;
if (!post) {
throw createError({ statusCode: 404, statusMessage: "Post not found" });
}
return post;
});<script setup>
const route = useRoute();
const page = computed(() => Number(route.query.page) || 1);
const { data } = await useFetch("/api/listing", {
query: { page },
// Refetch when the page number changes.
watch: [page]
});
</script>
<template>
<main>
<h1>Blog</h1>
<ul>
<li v-for="post in data?.posts" :key="post.id">
<NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink>
<p>{{ post.excerpt }}</p>
<time :datetime="post.publishedAt">
{{ new Date(post.publishedAt).toLocaleDateString() }}
</time>
</li>
</ul>
<NuxtLink
v-if="data?.meta?.hasNextPage"
:to="{ query: { page: page + 1 } }"
>
Next page
</NuxtLink>
</main>
</template><script setup>
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: "Post not found" });
}
useSeoMeta({
title: post.value.title,
description: post.value.description,
ogTitle: post.value.title,
ogDescription: post.value.description,
ogImage: post.value.coverImage,
ogType: "article"
});
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<span v-if="post.readingTime">{{ post.readingTime }} min read</span>
<span v-if="post.authors?.[0]">By {{ post.authors[0].name }}</span>
<!-- Trusted HTML from your own workspace. -->
<div v-html="post.content" />
</article>
</template>v-html renders markup without escaping it. That is correct for article bodies written by your own authors in your own workspace, and wrong for anything user-submitted.
The cached readers above stop your API allowance being spent on every visit. Route rules go a step further and cache the rendered HTML — Nitro's vocabulary for what Next.js calls ISR.
export default defineNuxtConfig({
routeRules: {
"/": { isr: 3600 },
"/blog": { isr: 3600 },
"/blog/**": { isr: 3600 },
"/sitemap.xml": { isr: 3600 },
// Never cache the webhook itself.
"/api/revalidate/**": { isr: false, cache: false }
}
});isr needs a platform that implements it — Vercel, Netlify, Cloudflare. On a plain Node server it does nothing, and the caching that matters is the cached readers from step 2, which hold the same one-hour window wherever the app runs.
Nitro has no revalidateTag. The same job is done against the cache storage directly: each cached reader is registered under a group that plays the part of a tag, so dropping that group's keys invalidates exactly those reads.
/* Rendered responses, where the deployment target caches them. */
const ROUTE_CACHE_GROUP = "nitro/routes";
/* storage.clear(base) does not remove namespaced keys on every driver, so the
keys are listed and removed explicitly — which behaves the same on memory,
filesystem, Redis and KV. */
async function purge(cache, prefix) {
const keys = await cache.getKeys(prefix);
await Promise.all(
keys.map((key) => cache.removeItem(key, { removeMeta: true }))
);
return keys.length;
}
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}));
const secret = body?.secret || getHeader(event, "x-api-key");
if (secret !== revalidationSecret()) {
setResponseStatus(event, 401);
return { error: "Invalid token" };
}
const cache = useStorage("cache");
// The same group names used by the cached readers in step 2.
const purged = await Promise.all([
purge(cache, "blogs"),
purge(cache, "categories"),
purge(cache, ROUTE_CACHE_GROUP)
]);
return {
success: true,
entries: purged.reduce((total, count) => total + count, 0)
};
});curl -X POST http://localhost:3000/api/revalidate/all \
-H "x-api-key: $REVALIDATION_SECRET" \
-H "content-type: application/json" \
-d '{"post":{"slug":"hello-world"}}'Nitro's cache mount is in-process memory by default. That is fine for one instance; before you run several, point it at shared storage or a webhook landing on one node will leave the others stale.
export default defineNuxtConfig({
nitro: {
storage: {
cache: { driver: "redis", url: process.env.REDIS_URL }
}
}
});To build pages as static files at deploy time, hand Nitro the routes to start from:
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
routes: ["/", "/blog"]
}
}
});With crawlLinks, Nitro follows the links on /blog to find every article, so you do not have to list them. Paginated listings need each page as an explicit route, or a link the crawler can follow.
Prerendering and the webhook solve the same problem in opposite directions: one builds everything ahead of time, the other keeps a running server fresh. Pick whichever matches how often your authors publish.