Developer documentation
Fetch content in route loaders, behind a cache you can invalidate by tag.
Remix has no data cache of its own, so this guide builds a small one — which is what the official starter does. Loaders run only on the server, and the .server.js suffix keeps your API key out of the browser bundle entirely.
A complete blog: a tagged cache with stale-while-revalidate, a publish webhook, category archives, pagination, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap and robots.txt. Remix 2, Tailwind CSS v4, MIT licensed.
npx degit contioreach/remix-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:3000Read them in one module, marked server-only with the .server.js suffix. Remix's compiler strips such a module out of the browser bundle, so the key cannot leak through an accidental import.
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");
/* Remix has no PUBLIC_ prefix convention and no build-time env inlining for
the browser, so anything the client needs is read here and handed down
through the root loader. The prefix below is just a naming convention to
mark which values are allowed to reach the page. */
export function publicConfig() {
return {
siteUrl: required("PUBLIC_SITE_URL"),
noIndex: process.env.PUBLIC_ALLOW_INDEXING !== "true"
};
}Remix strips server-only code per module, not per line. Read process.env inside a loader, an action, or a *.server.js file — a component module that reads it at the top level can end up in the browser build.
Next.js has revalidateTag, Astro has a tagged response cache, Nitro has cached function groups. Remix has none of these, so it is worth about sixty lines of your own — it is what lets a publish webhook drop exactly the affected content.
The semantics to aim for: a value is fresh for maxAge seconds, then served stale for up to swr seconds while a single refresh runs behind the request, so no visitor ever waits on the CMS.
const store = new Map();
/* Tracks the refresh in flight for a key, so a burst of requests hitting a
stale entry triggers one CMS call rather than one each. */
const inFlight = new Map();
const now = () => Date.now() / 1000;
export function invalidate(tags) {
const wanted = new Set(Array.isArray(tags) ? tags : [tags]);
let dropped = 0;
for (const [key, entry] of store) {
if (entry.tags.some((tag) => wanted.has(tag))) {
store.delete(key);
dropped += 1;
}
}
return dropped;
}
/**
* @param key include every argument that changes the result
* @param options { tags, maxAge, swr }
* @param load fetches the value on a miss
*/
export async function cached(key, { tags = [], maxAge = 3600, swr = 86400 }, load) {
const entry = store.get(key);
const age = entry ? now() - entry.storedAt : Infinity;
if (entry && age <= maxAge) return entry.value;
if (entry && age <= maxAge + swr) {
// Stale but usable: refresh behind this request and serve what we have.
if (!inFlight.has(key)) {
const refresh = load()
.then((value) => {
store.set(key, { value, tags, storedAt: now() });
return value;
})
.catch((error) => {
// Keep serving the stale value rather than propagating a CMS blip.
console.error(`Background revalidation failed for ${key}:`, error);
})
.finally(() => inFlight.delete(key));
inFlight.set(key, refresh);
}
return entry.value;
}
/* Cold or fully expired: the caller has to wait. Sharing the in-flight
promise keeps a thundering herd on a cold key down to one CMS call. */
if (inFlight.has(key)) return inFlight.get(key);
const pending = load()
.then((value) => {
store.set(key, { value, tags, storedAt: now() });
return value;
})
.finally(() => inFlight.delete(key));
inFlight.set(key, pending);
return pending;
}This is per-process and in-memory — honest for a single instance, and for the CDN-fronted deployments most blogs use. Run several instances and a webhook clears only the one it lands on; move the store to Redis before you scale out.
Every read goes through here, and every read carries the tags the webhook drops.
import { cached } from "./cache.server.js";
import { cmsApiKey, cmsApiUrl } from "./config.server.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;
}
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}` : "";
}
const read = (key, tags, endpoint) =>
cached(key, { tags, maxAge: 3600 }, () => apiRequest(endpoint));
export function getBlogs(params = {}) {
const qs = query({
page: params.page,
limit: params.limit,
category: params.category,
search: params.search,
minimal: params.minimal
});
return read(`blogs:list:${qs}`, ["blogs"], "/v1/blogs" + qs);
}
// minimal=false so the detail page gets the full HTML body.
export function getBlogBySlug(slug) {
const qs = query({ slug, minimal: "false" });
// Two tags: one to drop every listing, one to drop just this article.
return read(`blogs:by-slug:${slug}`, ["blogs", `blog-${slug}`], "/v1/blogs" + qs);
}
export function getCategories(params = {}) {
const qs = query({ limit: params.limit ?? 100 });
return read(`categories:list:${qs}`, ["categories"], "/v1/categories" + qs);
}
export async function getPost(slug) {
const response = await getBlogBySlug(slug);
// A slug lookup returns one object, not an array.
const post = Array.isArray(response.data) ? response.data[0] : response.data;
return post || null;
}The headers export lets a CDN answer without touching your server at all. Promise.allSettled means a CMS outage renders an empty state rather than taking the page down.
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { getBlogs, getCategories } from "~/lib/cms.server";
export async function loader({ request }) {
const url = new URL(request.url);
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const [postsResult, categoriesResult] = await Promise.allSettled([
getBlogs({ page, limit: 12, minimal: "true" }),
getCategories()
]);
return json({
posts: postsResult.status === "fulfilled" ? postsResult.value.data : [],
meta: postsResult.status === "fulfilled" ? postsResult.value.meta : null,
categories:
categoriesResult.status === "fulfilled" ? categoriesResult.value.data : []
});
}
export const headers = () => ({
// max-age=0 keeps the browser honest; s-maxage is what the CDN holds.
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400"
});
export default function BlogIndex() {
const { posts, meta } = useLoaderData();
return (
<main>
<h1>Blog</h1>
<ul>
{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>
{meta?.hasPrevPage && <Link to={`/blog?page=${meta.page - 1}`}>Previous</Link>}
{meta?.hasNextPage && <Link to={`/blog?page=${meta.page + 1}`}>Next</Link>}
</main>
);
}Two caches are at work now: yours, in front of the CMS, and the CDN's, in front of your server. Keep max-age at 0 so a reader always gets a fresh render on reload, and let s-maxage absorb the traffic.
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getPost } from "~/lib/cms.server";
export async function loader({ params }) {
const post = await getPost(params.slug);
// Throwing a Response is how a loader renders an error boundary.
if (!post) {
throw new Response("Post not found", { status: 404 });
}
return json({ post });
}
export const headers = () => ({
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400"
});
export const meta = ({ data }) => {
if (!data?.post) return [{ title: "Not found" }];
return [
{ title: data.post.title },
{ name: "description", content: data.post.description },
{ property: "og:title", content: data.post.title },
{ property: "og:description", content: data.post.description },
{ property: "og:image", content: data.post.coverImage },
{ property: "og:type", content: "article" }
];
};
export default function Post() {
const { post } = useLoaderData();
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 is the right tool for an article body written by your own authors in your own workspace, and the wrong one for anything a stranger can submit.
A resource route — a route module with an action and no default export — is a plain HTTP endpoint. This one receives the publish webhook and drops the matching cache entries.
import { json } from "@remix-run/node";
import { invalidate } from "~/lib/cache.server";
import { revalidationSecret } from "~/lib/config.server";
export async function action({ request }) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
const body = await request.json().catch(() => ({}));
const secret = body?.secret || request.headers.get("x-api-key");
if (secret !== revalidationSecret()) {
return 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}`);
const entries = invalidate(tags);
return json({ success: true, revalidated: { tags, entries } });
}
// A GET here is a mistake worth naming rather than a 404.
export const loader = () =>
json({ error: "Use POST to revalidate" }, { status: 405 });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"}}'A CDN holding a page for an hour will not notice this webhook — it clears your cache, not theirs. Keep s-maxage modest, or add a purge call here as well, otherwise the two caches disagree and authors see stale pages they cannot explain.
Remix v3 became React Router 7. The starter is Remix 2, and in React Router's framework mode the concepts are identical — loaders, actions, headers, meta. The migration for the code above is import paths and route file names.
| Remix v2 | React Router 7 | Notes |
|---|---|---|
| @remix-run/react | react-router | useLoaderData, Link, and friends. |
| @remix-run/node | react-router | json() is gone — return a plain object or Response.json. |
| File-name routing | routes.ts | Routes are configured rather than inferred, by default. |
| *.server.js suffix | *.server.ts suffix | Same convention, same guarantee. |
Nothing in the cache or the content client changes. Both are plain JavaScript behind a .server module, which either version treats the same way.
Put a CDN in front if you can. The cache headers above are written for one, and without a shared cache honouring s-maxage every visitor reaches your server — which is survivable, since your own cache still shields the CMS.