Developer documentation
Source your posts into the GraphQL layer at build time and generate a static page for each one.
Gatsby fetches content while it builds, so nothing reaches the browser but HTML — the API key never leaves your build machine. There is no plugin to install: gatsby-node.js reads the Content API directly.
A complete blog: CMS content sourced into Gatsby's GraphQL layer with an explicit schema, paginated listings and category archives, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap, and a publish webhook that triggers a rebuild. Gatsby 5, Tailwind CSS v4, MIT licensed.
npx degit contioreach/gatsby-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env
# add your API key to .env, then:
npm run developThe rest of this page builds the same integration from scratch, so you can add ContioReach to a Gatsby site you already have.
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret
BUILD_HOOK_URL=
GATSBY_SITE_URL=http://localhost:9000Gatsby does not load .env by itself, so load it at the top of gatsby-config.js:
require("dotenv").config({ path: ".env" });
const { siteUrl } = require("./src/lib/config");
module.exports = {
siteMetadata: {
title: "My Blog",
siteUrl: siteUrl()
},
trailingSlash: "never",
plugins: ["gatsby-plugin-postcss"]
};Gatsby inlines anything prefixed GATSBY_ into the browser bundle and keeps everything else on the build machine. Leave your API key unprefixed — a GATSBY_CMS_API_KEY would be published to every visitor.
function required(name, value) {
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. See .env.example.`
);
}
return value;
}
// Build-time only.
const cmsApiUrl = () => required("CMS_API_URL", process.env.CMS_API_URL);
const cmsApiKey = () => required("CMS_API_KEY", process.env.CMS_API_KEY);
const revalidationSecret = () =>
required("REVALIDATION_SECRET", process.env.REVALIDATION_SECRET);
/* Optional: without it the publish webhook reports that it has nothing to
call rather than pretending it worked. */
const buildHookUrl = () => process.env.BUILD_HOOK_URL || null;
// Inlined into the browser bundle by Gatsby.
const siteUrl = () => required("GATSBY_SITE_URL", process.env.GATSBY_SITE_URL);
const noIndex = () => process.env.GATSBY_ALLOW_INDEXING !== "true";
module.exports = {
cmsApiUrl,
cmsApiKey,
revalidationSecret,
buildHookUrl,
siteUrl,
noIndex
};Plain Node, imported only by gatsby-node.js. Note there is no cache here: Gatsby already is the cache. The other frameworks in this section cache because they fetch per request; this one fetches per build.
const { cmsApiKey, cmsApiUrl } = require("./config");
async function apiRequest(endpoint) {
const response = await fetch(`${cmsApiUrl()}${endpoint}`, {
headers: {
"Content-Type": "application/json",
"X-API-Key": cmsApiKey()
}
});
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;
}
/* Walks the CMS's pagination until it has everything. A build wants the whole
corpus, not a page of it — and limit is capped at 100. */
async function fetchAll(endpoint, params = {}) {
const out = [];
let page = 1;
for (;;) {
const search = new URLSearchParams({ ...params, page: String(page), limit: "100" });
const response = await apiRequest(`${endpoint}?${search}`);
out.push(...(response.data || []));
if (!response.meta?.hasNextPage) return out;
page += 1;
}
}
// minimal=false: the build fetches each post once and needs the body.
const getAllBlogs = () => fetchAll("/v1/blogs", { minimal: "false" });
const getAllCategories = () => fetchAll("/v1/categories");
module.exports = { getAllBlogs, getAllCategories };This step is easy to skip and expensive to skip. Gatsby infers GraphQL types from whatever happens to be in the data, so if every post in today's build has a cover image and tomorrow's does not, the type changes and a query that worked yesterday fails.
exports.createSchemaCustomization = ({ actions }) => {
actions.createTypes(`
type ContioAuthorInfo {
name: String
image: String
bio: String
twitter: String
website: String
}
type ContioTerm {
id: String
name: String
slug: String
postCount: Int
}
type ContioBlog implements Node {
cmsId: String!
slug: String!
title: String!
excerpt: String
description: String
coverImage: String
html: String
publishedAt: Date @dateformat
updatedAt: Date @dateformat
readingTime: String
authors: [ContioAuthorInfo!]!
categories: [ContioTerm!]!
category: String
categorySlug: String
}
type ContioCategory implements Node {
slug: String!
name: String!
description: String
postCount: Int
}
`);
};Non-null markers earn their keep here. Declaring authors as [ContioAuthorInfo!]! means a post that somehow arrives without one fails the build loudly, rather than rendering a byline-shaped hole on the live site.
Turning each post into a Gatsby node puts it in the GraphQL layer, so components query it the way they query any other Gatsby data.
const { getAllBlogs, getAllCategories } = require("./src/lib/cms");
exports.sourceNodes = async ({
actions,
createNodeId,
createContentDigest,
reporter
}) => {
const { createNode } = actions;
const timer = reporter.activityTimer("sourcing ContioReach content");
timer.start();
let blogs;
let categories;
try {
[blogs, categories] = await Promise.all([getAllBlogs(), getAllCategories()]);
} catch (error) {
/* Failing the build is the right call: a static site that builds green
with no content would be published and served to everyone. */
timer.panicOnBuild(`Could not reach the ContioReach API: ${error.message}`);
return;
}
for (const blog of blogs) {
createNode({
...blog,
cmsId: String(blog.id),
id: createNodeId(`ContioBlog-${blog.slug}`),
parent: null,
children: [],
internal: {
type: "ContioBlog",
contentDigest: createContentDigest(blog)
}
});
}
for (const category of categories) {
createNode({
...category,
id: createNodeId(`ContioCategory-${category.slug}`),
parent: null,
children: [],
internal: {
type: "ContioCategory",
contentDigest: createContentDigest(category)
}
});
}
timer.setStatus(`${blogs.length} posts, ${categories.length} categories`);
timer.end();
};Gatsby reserves id for its own node identifiers, so the CMS's id is carried as cmsId. Reusing the field would collide with the value createNodeId produces.
One page per article, plus paginated listings. A static site has no query string to read, so page two gets a real URL — /blog/2 — which is better for crawlers and for sharing than ?page=2 would have been anyway.
const path = require("path");
const POSTS_PER_PAGE = 12;
exports.createPages = async ({ graphql, actions, reporter }) => {
const { createPage } = actions;
const result = await graphql(`
{
posts: allContioBlog(sort: { publishedAt: DESC }) {
nodes {
slug
categorySlug
}
}
categories: allContioCategory {
nodes {
slug
}
}
}
`);
if (result.errors) {
reporter.panicOnBuild("Failed to query content", result.errors);
return;
}
const posts = result.data.posts.nodes;
// One page per article.
for (const post of posts) {
createPage({
path: `/blog/${post.slug}`,
component: path.resolve("./src/templates/BlogPost.jsx"),
// Passed to the template's page query as $slug.
context: { slug: post.slug }
});
}
// Paginated listings: /blog, /blog/2, /blog/3 …
const pageCount = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
for (let index = 0; index < pageCount; index += 1) {
createPage({
path: index === 0 ? "/blog" : `/blog/${index + 1}`,
component: path.resolve("./src/templates/BlogList.jsx"),
context: {
limit: POSTS_PER_PAGE,
skip: index * POSTS_PER_PAGE,
currentPage: index + 1,
pageCount
}
});
}
reporter.info(`Created ${posts.length} articles and ${pageCount} listing pages`);
};reporter.panicOnBuild rather than a thrown error: it fails the build with a readable message instead of a stack trace, which is what you want when a CI log is all you have to go on.
Everything in context is available to the page query as a variable, which is how skip and limit reach GraphQL.
import * as React from "react";
import { Link, graphql } from "gatsby";
export default function BlogList({ data, pageContext }) {
const { currentPage, pageCount } = pageContext;
return (
<main>
<h1>Blog</h1>
<ul>
{data.posts.nodes.map((post) => (
<li key={post.slug}>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
<p>{post.excerpt}</p>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
</li>
))}
</ul>
{currentPage > 1 && (
<Link to={currentPage === 2 ? "/blog" : `/blog/${currentPage - 1}`}>
Previous
</Link>
)}
{currentPage < pageCount && <Link to={`/blog/${currentPage + 1}`}>Next</Link>}
</main>
);
}
export const query = graphql`
query BlogListQuery($limit: Int!, $skip: Int!) {
posts: allContioBlog(sort: { publishedAt: DESC }, limit: $limit, skip: $skip) {
nodes {
title
slug
excerpt
publishedAt(formatString: "D MMMM YYYY")
coverImage
}
}
}
`;import * as React from "react";
import { graphql } from "gatsby";
export default function BlogPost({ data }) {
const post = data.contioBlog;
return (
<article>
<h1>{post.title}</h1>
{post.readingTime && <span>{post.readingTime}</span>}
{post.authors?.[0] && <span>By {post.authors[0].name}</span>}
{/* Trusted HTML from your own workspace. */}
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
/* Gatsby's Head API: exported from the page, rendered into the document head
at build time. */
export function Head({ data }) {
const post = data.contioBlog;
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.description} />
<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" />
</>
);
}
export const query = graphql`
query PostBySlug($slug: String!) {
contioBlog(slug: { eq: $slug }) {
title
slug
description
html
coverImage
publishedAt
readingTime
authors {
name
}
categories {
name
slug
}
}
}
`;dangerouslySetInnerHTML is appropriate for an article body written by your own authors in your own workspace, and not for anything a stranger can submit.
onPostBuild runs once the pages exist, so it can query the same data and write both files straight into the output. A plugin would work too; for two files this is less to keep in step with the rest of the repo.
exports.onPostBuild = async ({ graphql, reporter }) => {
const fs = require("fs/promises");
const { siteUrl, noIndex } = require("./src/lib/config");
const base = siteUrl();
const result = await graphql(`
{
posts: allContioBlog(sort: { publishedAt: DESC }) {
nodes {
slug
updatedAt
}
}
}
`);
if (result.errors) {
reporter.panicOnBuild("Failed to query content for the sitemap", result.errors);
return;
}
const now = new Date().toISOString();
const entry = (loc, lastmod, priority) =>
` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod || now}</lastmod>\n <priority>${priority}</priority>\n </url>`;
const urls = [
entry(`${base}/blog`, now, 0.9),
...result.data.posts.nodes.map((p) =>
entry(`${base}/blog/${p.slug}`, p.updatedAt, 0.8)
)
];
await fs.writeFile(
"public/sitemap.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`
);
/* Belt and braces with the per-page noindex tags: while the site is closed
off, crawlers are turned away at the door too. */
await fs.writeFile(
"public/robots.txt",
noIndex()
? "User-agent: *\nDisallow: /\n"
: `User-agent: *\nAllow: /\n\nSitemap: ${base}/sitemap.xml\n`
);
};This is where Gatsby genuinely differs from every other framework in this section, and it is worth being blunt about: a static site has no cache to purge. The HTML on disk came from the last build, and the only way new content appears is a new build.
Every major host exposes a URL you POST to in order to start a build. If you deploy the static output on its own, give that URL to ContioReach directly and write no code at all.
| 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. |
A raw build hook is a URL that starts a build for anyone who finds it. If your host runs Gatsby Functions, put one in front so the shared secret is checked first.
const { buildHookUrl, revalidationSecret } = require("../../lib/config");
export default async function handler(req, res) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const body = req.body || {};
const secret = body.secret || req.headers["x-api-key"];
if (secret !== revalidationSecret()) {
return res.status(401).json({ error: "Invalid token" });
}
const hook = buildHookUrl();
/* Saying so plainly beats returning success for something that did not
happen — a webhook that always reports OK is worse than one that reports
it has nowhere to forward to. */
if (!hook) {
return res.status(501).json({
success: false,
error: "No BUILD_HOOK_URL configured, so there is nothing to trigger.",
hint: "Set BUILD_HOOK_URL, or point the CMS webhook at your build hook directly."
});
}
const response = await fetch(hook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
trigger: "contioreach-publish",
slug: body.post?.slug ?? null
})
});
if (!response.ok) {
return res.status(500).json({
success: false,
error: `Build hook responded ${response.status}`
});
}
// 202: accepted, not done. The build still has to finish.
return res.status(202).json({
success: true,
message: "Rebuild triggered; the post goes live when the build finishes."
});
}Gatsby Functions need a host that runs them. On a pure static deployment this file does nothing — use the simple path above and delete it.
Either way a published post goes live when the build finishes, usually a minute or two. If your authors need it to be instant, a framework that renders on demand — Next.js, Nuxt, or Astro — will serve them better than Gatsby.
The build needs the API key. Without CMS_API_KEY set, sourcing panics and the build fails — which is the point: better than publishing an empty blog.