Developer documentation
Render your blog on the server, cache the responses, and ship no client-side framework.
Astro fits a ContioReach blog particularly well: pages are rendered from your content and sent as HTML, with a tagged response cache your publish webhook can invalidate. You can also build the whole site statically — both paths are below.
A complete blog: a tagged response cache, a publish webhook, category archives, pagination, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap and robots.txt — with no client-side framework at all. Astro 7, Tailwind CSS v4, MIT licensed.
npx degit contioreach/astro-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.
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret
PUBLIC_SITE_URL=http://localhost:4321Declare them in an astro:env schema. Astro then validates them at startup, so a missing or malformed value fails the build instead of shipping a site that 401s against the CMS.
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
// context: "server" keeps a value out of the client bundle entirely;
// access: "secret" additionally keeps it out of prerendered output.
CMS_API_URL: envField.string({ context: "server", access: "secret", url: true }),
CMS_API_KEY: envField.string({ context: "server", access: "secret" }),
REVALIDATION_SECRET: envField.string({ context: "server", access: "secret" }),
PUBLIC_SITE_URL: envField.string({ context: "client", access: "public", url: true })
}
}
});Server values are then imported from astro:env/server, which is a real import rather than a lookup on a global — so referencing one from client code is a build error instead of a leak.
import { CMS_API_KEY, CMS_API_URL } from "astro:env/server";import.meta.env.CMS_API_KEY still works, and unprefixed variables stay off the client there too. The schema is worth the few extra lines because it turns a missing key into a named startup error rather than a 401 you debug later.
Do not name it PUBLIC_CMS_API_KEY. That prefix is exactly what puts a value into client-side JavaScript.
The webhook and the response cache both need a server, so the starter renders on demand and adds an adapter. Individual routes can still opt into prerendering.
npx astro add node
# or: npx astro add vercel / netlify / cloudflareimport node from "@astrojs/node";
import { defineConfig } from "astro/config";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
site: process.env.PUBLIC_SITE_URL
});If you would rather build the whole site at deploy time and skip the server entirely, that is still a good option for a blog — see “Building statically instead” below.
A plain module, imported by your pages. Note there is no cache in front of it: Astro caches the rendered response instead, so a cache hit never reaches this file at all. One cache to reason about rather than two that can disagree.
import { CMS_API_KEY, CMS_API_URL } from "astro:env/server";
async function apiRequest(endpoint) {
const response = await fetch(`${CMS_API_URL}${endpoint}`, {
headers: {
"Content-Type": "application/json",
"X-API-Key": CMS_API_KEY
}
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const data = await response.json();
// A response can arrive with HTTP 200 and success: false.
if (!data.success) {
throw new Error(data.error?.message || "API 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}` : "";
}
export function getBlogs(params = {}) {
return apiRequest(
"/v1/blogs" +
query({
page: params.page,
limit: params.limit,
category: params.category,
tags: params.tags,
search: params.search,
minimal: params.minimal
})
);
}
export function getCategories(params = {}) {
return apiRequest("/v1/categories" + query({ limit: params.limit ?? 100 }));
}
export async function getPost(slug) {
// minimal=false so the detail page gets the full HTML body.
const response = await apiRequest("/v1/blogs" + query({ slug, minimal: "false" }));
// A slug lookup returns one object, not an array.
const post = Array.isArray(response.data) ? response.data[0] : response.data;
return post || null;
}Rendered per request, so pagination reads straight from the URL. Promise.allSettled means a CMS outage renders an empty state rather than taking the page down.
---
import { getBlogs, getCategories } from "../../lib/cms.js";
const page = Number(Astro.url.searchParams.get("page")) || 1;
const [postsResult, categoriesResult] = await Promise.allSettled([
getBlogs({ page, limit: 12, minimal: "true" }),
getCategories()
]);
const posts =
postsResult.status === "fulfilled" ? postsResult.value.data : [];
const meta =
postsResult.status === "fulfilled" ? postsResult.value.meta : null;
const categories =
categoriesResult.status === "fulfilled" ? categoriesResult.value.data : [];
---
<main>
<h1>Blog</h1>
<ul>
{categories.map((category) => (
<li><a href={`/blog/category/${category.slug}`}>{category.name}</a></li>
))}
</ul>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<p>{post.excerpt}</p>
<time datetime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString()}
</time>
</li>
))}
</ul>
{meta?.hasPrevPage && <a href={`/blog?page=${meta.page - 1}`}>Previous</a>}
{meta?.hasNextPage && <a href={`/blog?page=${meta.page + 1}`}>Next</a>}
</main>---
import { getPost } from "../../lib/cms.js";
const { slug } = Astro.params;
const post = await getPost(slug);
if (!post) {
return new Response(null, { status: 404, statusText: "Not found" });
}
---
<html lang="en">
<head>
<title>{post.title}</title>
<meta name="description" content={post.description} />
<link rel="canonical" href={new URL(`/blog/${post.slug}`, Astro.site)} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.description} />
{post.coverImage && <meta property="og:image" content={post.coverImage} />}
<meta property="og:type" content="article" />
</head>
<body>
<article>
<h1>{post.title}</h1>
{post.readingTime && <span>{post.readingTime} min read</span>}
{post.authors?.[0] && <span>By {post.authors[0].name}</span>}
<!-- Trusted HTML from your own workspace. -->
<div set:html={post.content} />
</article>
</body>
</html>set:html injects markup without escaping it. That is what you want for an article body written by your own authors, and not something to point at user-submitted content.
Without a cache, every visitor costs an API call. Astro's response cache holds a rendered page for maxAge, then serves it stale for up to swr seconds while it refreshes behind the request — and the tags are what the webhook invalidates.
import { defineConfig, memoryCache } from "astro/config";
export default defineConfig({
cache: { provider: memoryCache({ max: 500 }) },
routeRules: {
"/": { maxAge: 3600, swr: 86400, tags: ["blogs", "categories"] },
"/blog": { maxAge: 3600, swr: 86400, tags: ["blogs", "categories"] },
"/blog/[slug]": { maxAge: 3600, swr: 86400, tags: ["blogs"] },
"/blog/category/[slug]": { maxAge: 3600, swr: 86400, tags: ["blogs", "categories"] },
"/sitemap.xml": { maxAge: 3600, swr: 86400, tags: ["blogs", "categories"] }
}
});The cache is a no-op in astro dev. Run astro build and astro preview — or deploy — to see it working, otherwise it looks broken when it is not.
memoryCache is per-process. That is fine for one instance; before running several, use a shared provider so a webhook landing on one node does not leave the others stale.
This is the piece the route rules above exist for: invalidate({ tags }) drops exactly the cached responses carrying those tags and leaves the rest warm.
import { REVALIDATION_SECRET } from "astro:env/server";
export const prerender = false;
export async function POST(context) {
const body = await context.request.json().catch(() => ({}));
const secret = body?.secret || context.request.headers.get("x-api-key");
if (secret !== REVALIDATION_SECRET) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
const tags = ["blogs", "categories"];
// When the webhook names a post, its own tag goes too — the listings are
// dropped by the shared tags either way.
if (body?.post?.slug) tags.push(`blog-${body.post.slug}`);
await context.cache.invalidate({ tags });
return Response.json({ success: true, revalidated: { tags } });
}npm run build && npm run preview
curl -X POST http://localhost:4321/api/revalidate/all \
-H "x-api-key: $REVALIDATION_SECRET" \
-H "content-type: application/json" \
-d '{"post":{"slug":"hello-world"}}'If your authors can wait a minute or two for a build, a fully static Astro site is simpler than all of the above: no server, no cache, no webhook endpoint. Prerender the routes and list the pages at build time.
---
import { getBlogs, getPost } from "../../lib/cms.js";
export const prerender = true;
export async function getStaticPaths() {
const posts = [];
let page = 1;
let hasNextPage = true;
// limit is capped at 100, so walk the pages.
while (hasNextPage) {
const { data, meta } = await getBlogs({ page, limit: 100, minimal: "true" });
posts.push(...data);
hasNextPage = meta.hasNextPage;
page += 1;
}
return posts.map((post) => ({ params: { slug: post.slug } }));
}
const post = await getPost(Astro.params.slug);
---
<article>
<h1>{post.title}</h1>
<div set:html={post.content} />
</article>Astro's paginate() helper covers numbered listings — /blog, /blog/2, /blog/3 — from one file:
---
import { getBlogs } from "../../lib/cms.js";
export const prerender = true;
export async function getStaticPaths({ paginate }) {
const posts = [];
let page = 1;
let hasNextPage = true;
while (hasNextPage) {
const { data, meta } = await getBlogs({ page, limit: 100, minimal: "true" });
posts.push(...data);
hasNextPage = meta.hasNextPage;
page += 1;
}
return paginate(posts, { pageSize: 12 });
}
const { page } = Astro.props;
---
<ul>
{page.data.map((post) => (
<li><a href={`/blog/${post.slug}`}>{post.title}</a></li>
))}
</ul>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
{page.url.next && <a href={page.url.next}>Next</a>}The [...page] rest parameter is what lets one file serve both /blog and /blog/2. A plain [page].astro would not match the un-numbered first page.
A static site has no cache to invalidate, so the webhook points at your host's build hook instead — no endpoint for you to write.
| Host | Where | Notes |
|---|---|---|
| Netlify | Build & deploy → Build hooks | A POST URL with the token in the path. |
| Vercel | Settings → Git → Deploy Hooks | Per branch. |
| Cloudflare Pages | Settings → Builds → Deploy hooks | Per project. |
Mixing the two is normal, and prerender is set per file: keep the listing static and render articles on demand, or the reverse.
The build needs the API key either way. Without CMS_API_KEY set, the env schema fails the build — which is the point: better than publishing an empty blog.