Start free. Scale as your blog grows.Get started

Developer documentation

React

Use ContioReach from a React single-page app, with the small API server one needs.

This guide is a little different from the others, because a single-page app has no server to keep a secret in. The official starter answers that with about a hundred lines of Express — which also turns out to be where the SEO problem gets solved.

Start from the official starter

contioreach/react-starter-contioreach

A complete blog: a Vite SPA plus an API server that holds the CMS key, caches reads with tags, answers the publish webhook, and fills in the head of index.html per route so crawlers get real metadata. React 19, react-router, Express 5, Tailwind CSS v4, MIT licensed.

Terminal
npx degit contioreach/react-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env
# add your API key to .env, then:
npm run dev

npm run dev starts both halves at once — the API server and the Vite client. The rest of this page builds the same thing from scratch.

1. Why a browser-only app needs a backend

Every other guide here fetches content on a server. A plain React app has no server: the build produces HTML, CSS, and JavaScript that run entirely in the visitor's browser. Anything that code can read, the visitor can read too — in the network tab, in the bundle, in devtools.

Your ContioReach API key reads your entire workspace. Putting it in a Vite env variable (VITE_CMS_API_KEY) or in fetch code that runs in the browser publishes it to everyone who visits your site. Rotating it later does not undo that.

So the browser talks to your server, and your server talks to ContioReach

  1. Browser requests /api/listing from your own server
  2. Your server adds the X-API-Key header
  3. ContioReach returns the posts
  4. Your server caches the response and passes it on
  5. React renders it

In production that server does double duty: it serves the built client as well, so the whole app is one origin and one deployment. There is no CORS to configure and no separate static host to wire up.

2. Configuration

.env
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:3000
PORT=3000

Read in one module, which the client build never imports. Failing at boot beats a site that 401s on its first visitor.

server/config.js
function required(name) {
  const value = process.env[name];

  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. See .env.example.`
    );
  }

  return value;
}

export const cmsApiUrl = () => required("CMS_API_URL");
export const cmsApiKey = () => required("CMS_API_KEY");
export const revalidationSecret = () => required("REVALIDATION_SECRET");
export const PORT = Number.parseInt(process.env.PORT, 10) || 3000;

/* The values the browser is allowed to know. Served at runtime from
   GET /api/config rather than baked into the bundle, so one build artifact
   can run in staging and in production. */
export function publicConfig() {
  return {
    siteUrl: required("PUBLIC_SITE_URL"),
    noIndex: process.env.PUBLIC_ALLOW_INDEXING !== "true"
  };
}

export function assertConfig() {
  cmsApiUrl();
  cmsApiKey();
  revalidationSecret();
  publicConfig();
}

Serving public config from an endpoint instead of inlining it with a VITE_ prefix is what lets you promote one build between environments. It also keeps the habit clean: nothing about the CMS is ever a build-time constant.

3. A cache with tags

Without a cache, every visitor costs an API call. The starter uses the same small tagged cache as the SvelteKit and Remix guides — a value is fresh for maxAge, then served stale for up to swr while one refresh runs behind the request, and invalidate(tags) drops exactly the matching entries.

server/cache.js
/* Full implementation is in the starter — about sixty lines. The interface
   is all the rest of the server needs: */

export function invalidate(tags) { /* drops every entry carrying a tag */ }

export async function cached(key, { tags, maxAge, swr }, load) {
  /* fresh → return it
     stale → return it, refresh behind the request
     cold  → await load(), sharing any in-flight promise for the same key */
}

Every CMS read then goes through it, carrying the tags the webhook will drop:

server/cms.js
import { cached } from "./cache.js";
import { cmsApiKey, cmsApiUrl } from "./config.js";

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;
}

const read = (key, tags, endpoint) =>
  cached(key, { tags, maxAge: 3600 }, () => apiRequest(endpoint));

export function getBlogs({ page = 1, limit = 12, category } = {}) {
  const params = new URLSearchParams({
    page: String(page),
    limit: String(limit),
    minimal: "true"
  });

  if (category) params.set("category", category);

  return read(`blogs:list:${params}`, ["blogs"], `/v1/blogs?${params}`);
}

export async function getPost(slug) {
  const params = new URLSearchParams({ slug, minimal: "false" });

  const result = await read(
    `blogs:by-slug:${slug}`,
    ["blogs", `blog-${slug}`],
    `/v1/blogs?${params}`
  );

  // A slug lookup returns one object, not an array.
  const post = Array.isArray(result.data) ? result.data[0] : result.data;
  return post || null;
}

The cache is per-process and in-memory. Fine for one instance; run several and a webhook clears only the one it lands on, so move the store to Redis before you scale out.

4. The server

Express 5. It exposes the endpoints the client calls, sets CDN-friendly headers, and in production serves the built client too.

server/index.js
import path from "node:path";
import express from "express";
import { getBlogs, getPost } from "./cms.js";
import { PORT, assertConfig, publicConfig } from "./config.js";

assertConfig();

const app = express();
app.use(express.json());
app.disable("x-powered-by");

/* The in-process cache keeps these off the CMS; these headers let a CDN in
   front of the app hold the responses too. */
const cacheHeader = "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400";

// The values the browser is allowed to know. Never the CMS key.
app.get("/api/config", (_req, res) => {
  res.set("Cache-Control", "public, max-age=0, s-maxage=60").json(publicConfig());
});

app.get("/api/listing", async (req, res, next) => {
  try {
    const result = await getBlogs({
      page: Math.max(1, Number.parseInt(req.query.page, 10) || 1),
      category: req.query.category || undefined
    });

    res.set("Cache-Control", cacheHeader).json({
      posts: result.data,
      meta: result.meta
    });
  } catch (error) {
    next(error);
  }
});

app.get("/api/posts/:slug", async (req, res, next) => {
  try {
    const post = await getPost(req.params.slug);
    if (!post) return res.status(404).json({ error: "Blog post not found" });

    res.set("Cache-Control", cacheHeader).json({ post });
  } catch (error) {
    next(error);
  }
});

app.use((error, _req, res, _next) => {
  console.error("Server error:", error);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(PORT, () => console.log(`Ready on http://localhost:${PORT}`));

5. Running both halves in development

In development the client runs on Vite's port and the API on its own. A proxy makes them one origin, so the app code never needs to know the difference between dev and production.

vite.config.js
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],

  build: {
    // The API server serves this directory in production.
    outDir: "dist/client",
    emptyOutDir: true
  },

  server: {
    proxy: {
      "/api": { target: "http://localhost:3000", changeOrigin: true }
    }
  }
});
package.json
{
  "scripts": {
    "dev": "concurrently -n server,client \"npm:dev:server\" \"npm:dev:client\"",
    "dev:client": "vite",
    "dev:server": "node --watch --env-file=.env server/index.js",
    "build": "vite build",
    "start": "node --env-file=.env server/index.js"
  }
}

--env-file is built into Node 20.6+, so there is no dotenv dependency here.

6. Fetching in React

One module is the client's only door to data. The CMS hostname and key are never in it, and never in the bundle.

src/lib/api.js
async function request(path) {
  const response = await fetch(path, { headers: { Accept: "application/json" } });

  if (!response.ok) {
    const error = new Error(`Request failed: ${response.status}`);
    error.status = response.status;
    throw error;
  }

  return response.json();
}

export const getConfig = () => request("/api/config");
export const getPost = (slug) => request(`/api/posts/${encodeURIComponent(slug)}`);

export function getListing({ page = 1, category } = {}) {
  const params = new URLSearchParams({ page: String(page) });
  if (category) params.set("category", category);
  return request(`/api/listing?${params}`);
}

And one hook covers the three states any remote data has. The important detail is the run id: it stops a slow response for the previous page rendering over the current one.

src/lib/useAsync.js
import { useEffect, useRef, useState } from "react";

export function useAsync(loader, deps) {
  const [state, setState] = useState({ data: null, error: null, loading: true });

  /* Incremented on every run; a resolved promise whose id no longer matches
     belongs to a navigation the user has already left behind. */
  const runId = useRef(0);

  useEffect(() => {
    const id = ++runId.current;
    setState((previous) => ({ ...previous, loading: true, error: null }));

    loader()
      .then((data) => {
        if (id === runId.current) setState({ data, error: null, loading: false });
      })
      .catch((error) => {
        if (id === runId.current) setState({ data: null, error, loading: false });
      });

    return () => {
      // A cleanup means this run is stale, even if it is still in flight.
      if (id === runId.current) runId.current += 1;
    };
  }, deps);

  return state;
}

Thirty lines rather than a query library, because there are only a handful of call sites. If your app grows past that, TanStack Query or SWR give you caching and deduplication on the client as well.

7. The components

Listing

src/pages/ListingPage.jsx
import { Link, useSearchParams } from "react-router";
import { getListing } from "@/lib/api";
import { useAsync } from "@/lib/useAsync";

export function ListingPage() {
  const [searchParams] = useSearchParams();
  const page = Number(searchParams.get("page")) || 1;

  const { data, error, loading } = useAsync(() => getListing({ page }), [page]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Could not load posts.</p>;

  return (
    <main>
      <h1>Blog</h1>

      <ul>
        {data.posts.map((post) => (
          <li key={post.id}>
            <Link to={`/blog/${post.slug}`}>{post.title}</Link>
            <p>{post.excerpt}</p>
            <time dateTime={post.publishedAt}>
              {new Date(post.publishedAt).toLocaleDateString()}
            </time>
          </li>
        ))}
      </ul>

      {data.meta.hasNextPage && <Link to={`/blog?page=${page + 1}`}>Next page</Link>}
    </main>
  );
}

Article

src/pages/BlogPostPage.jsx
import { useParams } from "react-router";
import { getPost } from "@/lib/api";
import { useAsync } from "@/lib/useAsync";

export function BlogPostPage() {
  const { slug } = useParams();
  const { data, error, loading } = useAsync(() => getPost(slug), [slug]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Post not found.</p>;

  const { post } = data;

  return (
    <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 dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

dangerouslySetInnerHTML suits an article body written by your own authors. Never use it for content a stranger can submit.

8. SEO, and what the server can do about it

A client-rendered app sends crawlers an empty div. Google will usually execute the JavaScript eventually, but eventually is not a crawl budget you control — and social previews, link unfurlers and most AI answer engines do not run JavaScript at all.

Because you already have a server, you can fix the metadata half of this. Before it sends index.html, the server resolves the route against the same cached CMS data and fills in the head.

server/index.js
/* Every non-API GET returns index.html with the head filled in for that
   route — and with the right status, because the server resolved the route
   against the CMS. */
app.get(/^\/(?!api\/).*/, async (req, res, next) => {
  try {
    const template = await readFile(path.join(clientDir, "index.html"), "utf8");
    const { head, status } = await headFor(req.path);

    res
      .status(status)
      .type("html")
      .set("Cache-Control", status === 200 ? cacheHeader : "no-store")
      .send(template.replace("<!--seo-->", head));
  } catch (error) {
    next(error);
  }
});

headFor builds a real title, description, canonical, Open Graph, Twitter card and JSON-LD for the requested path. Two things this buys you:

  • Crawlers and unfurlers get correct metadata on first byte, with no JavaScript executed.
  • A dead URL answers a real 404. Client-side routing returns HTTP 200 for everything, and a 200 that says "not found" is a soft 404 — a problem search engines name explicitly.

Be clear about what this is not. The metadata is genuinely server-rendered; the article body is still rendered by React in the browser. That is the right trade for a SPA and a real improvement over shipping nothing — but if organic search is the main channel for this content, a server-rendered framework will serve it better.

If the React app is behind a login — a dashboard, an internal tool, a help centre for signed-in users — none of this applies. Nobody is indexing it anyway.

9. Publish instantly with a webhook

The tags from step 3 are what this drops.

server/index.js
import { invalidate } from "./cache.js";
import { revalidationSecret } from "./config.js";

app.post("/api/revalidate/all", (req, res) => {
  const body = req.body || {};
  const secret = body.secret || req.get("x-api-key");

  if (secret !== revalidationSecret()) {
    return res.status(401).json({ error: "Invalid token" });
  }

  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}`);

  const entries = invalidate(tags);

  res.json({ success: true, revalidated: { tags, entries } });
});

Register it

  1. 1In your workspace, open Developer Settings → Webhook Integration
  2. 2Enter https://your-site.com/api/revalidate/all as the URL
  3. 3Generate a secret and save it as REVALIDATION_SECRET in your host's environment
  4. 4Publish a post and confirm the listing updates

Test it locally

Terminal
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"}}'

10. Deploy

One build, one process, one origin: the server serves the API and the client together.

  1. 1Run npm run build to produce dist/client
  2. 2Deploy the whole project anywhere that runs Node — Railway, Render, Fly.io, a container, a VM
  3. 3Set CMS_API_URL, CMS_API_KEY, REVALIDATION_SECRET, and PUBLIC_SITE_URL on the server
  4. 4Start it with npm start, and add the /api/revalidate/all URL as your publish webhook
server/index.js
// Hashed assets are immutable; index.html is never cached, because the head
// is filled in per request.
app.use(
  express.static(clientDir, {
    index: false,
    setHeaders(res, filePath) {
      if (filePath.includes(`${path.sep}assets${path.sep}`)) {
        res.set("Cache-Control", "public, max-age=31536000, immutable");
      }
    }
  })
);

The client build must never be given the API key, even under a correctly-named variable. Nothing in src/ should import anything from server/ — that separation is the whole security model here.