Start free. Scale as your blog grows.Get started

Developer documentation

Gatsby

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.

Start from the official starter

contioreach/gatsby-starter-contioreach

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.

Terminal
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 develop

The rest of this page builds the same integration from scratch, so you can add ContioReach to a Gatsby site you already have.

1. Environment variables

.env
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:9000

Gatsby does not load .env by itself, so load it at the top of gatsby-config.js:

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.

src/lib/config.js
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
};

2. The content client

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.

src/lib/cms.js
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 };

3. Declare the schema

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.

gatsby-node.js
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.

4. Source the nodes

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.

gatsby-node.js
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.

5. Create the pages

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.

gatsby-node.js
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.

6. The templates

Everything in context is available to the page query as a variable, which is how skip and limit reach GraphQL.

Listing

src/templates/BlogList.jsx
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
      }
    }
  }
`;

Article

src/templates/BlogPost.jsx
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.

7. Sitemap and robots.txt

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.

gatsby-node.js
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`
  );
};

8. Publishing new posts

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.

  1. An author publishes in ContioReach
  2. ContioReach POSTs your webhook
  3. A rebuild is triggered
  4. The post is live when the build finishes

The simple path: point the webhook at your host

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.

HostWhereNotes
NetlifyBuild & deploy → Build hooksA POST URL with the token in the path.
VercelSettings → Git → Deploy HooksPer branch.
Cloudflare PagesSettings → Builds → Deploy hooksPer project.

The guarded path: a Gatsby Function in front of it

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.

src/api/revalidate/all.js
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.

9. Deploy

  1. 1Add CMS_API_URL, CMS_API_KEY, and GATSBY_SITE_URL to your host's environment variables
  2. 2Deploy — the build sources your posts and writes a page for each one
  3. 3Add your build hook, or the deployed /api/revalidate/all URL, as the ContioReach publish webhook

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.