Custom cache module: TTL, stale-while-revalidate, concurrent miss coalescing, retaining stale values on failed refresh, tag-based invalidation, with node:test coverage
remix-starter-contioreach — Remix Blog Template
By contioreach
A blog starter built on Remix v2 and the ContioReach headless CMS, including caching, on-demand revalidation, category archives, SEO metadata, JSON-LD and sitemaps.
Explore the design
Compare the page layouts and mobile experience. Open any screenshot for a closer look.
Actual screenshot captured by the server browser.
All pages & views 3
Screenshots show visual appearance; interactive behavior may differ.
What stands out
A quick read of the recorded project description.
Signed POST /api/revalidate/all webhook, supports clearing by article slug tag individually, with the secret allowed in the request body or the X-API-Key header
Route pages export Cache-Control headers (s-maxage and stale-while-revalidate) to facilitate CDN caching of rendered HTML
Heading anchors and table of contents are generated in the loader, so they share the same source and cannot drift, and the raw body is not sent twice
A unified SEO helper generates canonical, Open Graph, Twitter card and other meta tags, and outputs BlogPosting and BreadcrumbList JSON-LD
Search engine indexing is disabled by default to avoid duplicate content between demo deployments and production sites
Template Details
This is a blog starter built with Remix v2 and React 18, using ContioReach headless CMS as the content backend. The project reads CMS data in loaders and implements TTL, stale-while-revalidate, concurrent request coalescing and tag-based invalidation through a custom cache module; it also provides a signed POST /api/revalidate/all webhook so the CMS can trigger tag-based cache purging on publish. Routes cover the blog home page (paginated), article pages, category archive pages, plus sitemap.xml and robots.txt. Article body heading anchors and table of contents are generated in the loader, and the raw HTML is no longer sent in the payload. For SEO, a unified helper generates canonical, Open Graph, Twitter card and other meta tags, and outputs JSON-LD for BlogPosting and BreadcrumbList. Styling uses Tailwind CSS v4, with no UI library, no MDX pipeline and no database.
Limitations to note: the cache is an in-process memory implementation, so in multi-instance deployments the webhook can only clear the instance that received the request; you need to replace it with shared storage or pair it with CDN purging. On serverless targets, the cache only survives while the instance is warm. Indexing is disabled by default (when PUBLIC_ALLOW_INDEXING is not "true", it outputs noindex, nofollow and robots.txt disallows all); you must explicitly enable it before going live. When an article has no cover image, it falls back to /og-default.png, which you need to replace yourself. The README also notes that Remix v2 is in maintenance status, with future evolution to React Router v7+, and Remix v3 is a separate rewrite.
Is it right for your project?
Use Cases
- Building a blog site with category archives and pagination using ContioReach as the content backend
- Content sites that need precise tag-based cache invalidation after CMS publishing and CDN caching of HTML
- Development projects looking for a reference for data fetching in Remix v2 loaders, caching and SEO metadata organization
- Editorial content sites that need article tables of contents, JSON-LD structured data and sitemaps
Suitable For
- Teams or individuals using the ContioReach headless CMS and accepting its API
- Developers familiar with Remix and React who can adjust caching and deployment configuration themselves
- Blog projects deployed on a single instance or behind a CDN that do not yet need shared caching
- Developers who need modifiable, commercially usable starter code under the MIT license
Getting started & customization
Use these recorded setup notes alongside the repository instructions for your chosen version.
- 1
Get the code via npx degit contioreach/remix-starter-contioreach my-blog, install dependencies, then copy .env.example to .env
- 2
Obtain CMS_API_KEY and REVALIDATION_SECRET from the ContioReach workspace and fill them into environment variables; the free plan is sufficient
- 3
The PUBLIC_ prefix is a custom convention in this repository; four public values are passed to the client via the root loader, and CMS secrets do not enter that payload
- 4
Before going live, set PUBLIC_ALLOW_INDEXING to "true" to enable indexing, and point PUBLIC_SITE_URL to the real domain
- 5
In multi-instance deployments, replace the in-memory cache with shared storage (such as Redis) or trigger CDN purging on the same webhook
License & source material
Usage conditions & limitations
Free to use · keep license noticeThis entry covers the free version under the MIT license in the public repository; the free scope is the code of that version and does not mean external services or third-party assets are free.
Must comply with when using
- You may use, modify, commercially use and distribute the code of this version for free.
- When distributing copies or substantial portions of the code, retain the original author's copyright notice and the full MIT license notice.
- The code is provided as is; warranties and limitations of liability are governed by the original license and applicable law.
Additional costs & items to verify
- Use requires registering a ContioReach account and obtaining CMS_API_KEY and REVALIDATION_SECRET; the free plan is sufficient, but the content backend depends on this external service.
- The cache is an in-process memory implementation; in multi-instance deployments the webhook only clears the instance that received the request, so you need to replace it with shared storage or pair it with CDN purging.
- On serverless targets, the cache only survives while the instance is warm; you need to rely on Cache-Control headers and treat the webhook as a CDN purge trigger.
- Search engine indexing is disabled by default; when PUBLIC_ALLOW_INDEXING is not "true", it outputs noindex, nofollow and robots.txt disallows all, so you must explicitly enable it before going live.
- When an article has no cover image, it falls back to /og-default.png; you need to replace this placeholder asset before going live.
- Demo images, fonts, icons and other third-party assets need to be checked against their respective licenses and cannot be judged solely by the code's MIT license.
- External services such as domains, hosting, databases, payments or AI may incur additional charges.
View Original Text Basis
Permission is hereby granted, free of charge
Original License Text
Get `CMS_API_KEY` and `REVALIDATION_SECRET` from your ContioReach workspace settings (the free plan is enough).
Original README
the cache is per-process and in-memory, so a webhook that lands on one instance only invalidates that instance
Original README
On a serverless target, the in-memory cache lives only as long as a warm instance.
Original README
**Indexing is off by default.** Until `PUBLIC_ALLOW_INDEXING` is exactly `"true"`
Original README
https://github.com/contioreach/remix-starter-contioreach/blob/main/LICENSEhttps://github.com/contioreach/remix-starter-contioreach/blob/main/README.mdPosts fall back to `/og-default.png` when they have no cover image — drop your own into `public/` before launch.
Original README
Compiled by DeepSeek based on repository license and README ·2026-10-03. Results only cover the materials read; dependencies, images, fonts, and subsequent term changes are subject to their respective original texts.
Project checks & description sources
Template Information
- Source platforms
- GitHub
- Documentation and maintenance reference score
- More documented · 85 / 100
- Technologies
- Remix · React
- Website type
- Blog · Starter
How this score is calculated
Our heuristic uses license records (up to 30), a demo link (12), a preview (10), stars (15), recorded maintenance (15), description completeness (8), feature records (5) and classification data (5). It is not a code, security or performance test, or a Google score. A link does not establish tested functionality.
What to verify for this project
- Repository update recorded
- 2026-10-01
- Screenshot last checked
- 2026-10-03
A demo link is listed. Check it with your own content and viewport sizes; its presence does not confirm that forms, authentication or payments work.
The catalog identifies Remix as the primary stack. Check the actual dependency manifest and lockfile for versions and hosting requirements. These notes use recorded metadata, not a code audit or a deployment test.
Use a selection guide →How this description was prepared
AI-assisted description based on catalog records and repository material. Repository authors are not automatically the reviewers of this description.
Review declaration: [email protected] · 2026-10-03
Inspect the recorded source material
SHA-256: fcf4d45269690d20d2d92d6e8b172cb57fa87c12c1d46a8b8034d764559ba694
2026-10-03T12:15:34.936Z
Source file / repository# Remix Headless CMS Starter
A complete, production-shaped blog built with **Remix v2** and a **headless CMS**, using [ContioReach](https://contioreach.com) as the content backend.
Not a toy. It ships the things a real content site actually needs and most examples skip: cached CMS reads with stale-while-revalidate and real tag invalidation, an on-demand revalidation webhook, category archives, pagination, a table of contents generated from the article body, full SEO metadata, JSON-LD, and a sitemap.
```bash
npx degit contioreach/remix-starter-contioreach my-blog
cd my-blog && npm install
cp .env.example .env # add your API key
npm run dev
```
> Looking for another stack? See the [Next.js](https://github.com/contioreach/nextjs-starter-contioreach), [Nuxt](https://github.com/contioreach/nuxtjs-starter-contioreach), [Astro](https://github.com/contioreach/astro-starter-contioreach), [SvelteKit](https://github.com/contioreach/sveltekit-starter-contioreach), [Gatsby](https://github.com/contioreach/gatsby-starter-contioreach), [React](https://github.com/contioreach/react-starter-contioreach) and [Vue](https://github.com/contioreach/vue-starter-contioreach) examples.
### A note on which Remix this is
This is **Remix v2** (`@remix-run/*` 2.x, React 18) — the stable release, and what "Remix" means to most people today. Worth knowing before you start a long-lived project on it:
- Remix v2 is effectively a maintenance branch. Its development continued as **React Router v7+**, which has the same loader/action model; migrating is a documented, mostly mechanical path.
- **Remix v3** is a separate rewrite, in release candidate at the time of writing.
If you are starting fresh and have no attachment to the Remix name, React Router's framework mode is the more future-proof choice, and nearly everything in this repo transfers to it unchanged.
---
## What's in the box
| Feature | Where |
| --- | --- |
| Blog index with pagination | [`app/routes/blog._index.jsx`](app/routes/blog._index.jsx) |
| Article page | [`app/routes/blog.$slug.jsx`](app/routes/blog.%24slug.jsx) |
| Category archives | [`app/routes/blog.category.$slug.jsx`](app/routes/blog.category.%24slug.jsx) |
| CMS client and transforms | [`app/lib/cms.server.js`](app/lib/cms.server.js) |
| Cache: TTL, SWR, single-flight, tags | [`app/lib/cache.server.js`](app/lib/cache.server.js) |
| …and its tests | [`app/lib/cache.test.js`](app/lib/cache.test.js) |
| On-demand revalidation webhook | [`app/routes/api.revalidate.all.jsx`](app/routes/api.revalidate.all.jsx) |
| Table of contents + stable heading anchors | [`app/lib/content.js`](app/lib/content.js) |
| Metadata, Open Graph, JSON-LD | [`app/lib/seo.js`](app/lib/seo.js), [`app/lib/schema.js`](app/lib/schema.js) |
| Sitemap and robots | [`app/routes/sitemap[.]xml.jsx`](app/routes/sitemap%5B.%5Dxml.jsx), [`app/routes/robots[.]txt.jsx`](app/routes/robots%5B.%5Dtxt.jsx) |
| Article typography (raw CMS HTML) | [`app/styles/tailwind.css`](app/styles/tailwind.css) |
Styling is Tailwind CSS v4. No UI library, no MDX pipeline, no database — the CMS is the only backend.
---
## 1. Environment variables
Copy the template and fill it in:
```bash
cp .env.example .env
```
```env
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=cms_xxxxxxxxxxxxxxxxxxxxxxxx
REVALIDATION_SECRET=revalidate_xxxxxxxxxxxx
PUBLIC_SITE_URL=http://localhost:3000
PUBLIC_ALLOW_INDEXING=false
PUBLIC_SIGNUP_URL=https://app.contioreach.com/signup
PUBLIC_LOGIN_URL=https://app.contioreach.com/login
```
Get `CMS_API_KEY` and `REVALIDATION_SECRET` from your ContioReach workspace settings (the free plan is enough). Every variable is read in one place — [`app/lib/config.server.js`](app/lib/config.server.js) — which throws a named error when one is missing.
**Remix has no `PUBLIC_` convention and no build-time env inlining for the browser.** That prefix is this repo's own, marking which four values are meant to reach the page. They are read on the server and handed to the client through the **root loader** ([`app/root.jsx`](app/root.jsx)); components read them back with the `useSite()` hook ([`app/lib/site.js`](app/lib/site.js)). The CMS key is never part of that payload.
**The API key cannot leak by construction.** [`cms.server.js`](app/lib/cms.server.js), [`cache.server.js`](app/lib/cache.server.js) and [`config.server.js`](app/lib/config.server.js) use Remix's `.server.js` suffix, so the compiler strips them from the browser bundle. (Checked, not assumed: the built `build/client` contains no trace of the key.)
Running the built server:
```bash
npm run build
node --env-file=.env ./node_modules/.bin/remix-serve ./build/server/index.js
```
---
## 2. Fetching content
Every route reads its data in a `loader`, which only ever runs on the server — including across client-side navigations, where Remix fetches the serialized loader data for the next route rather than letting the browser talk to the CMS.
Components never see the API's shape: a transform layer maps it first, so swapping in Contentful, Sanity or Strapi is `apiRequest` plus two `transform*` functions, without touching a component.
The article body is prepared in the loader ([`app/lib/content.js`](app/lib/content.js)): heading anchors added, and the table of contents built from that same pass so the two cannot drift. The raw body is dropped from the payload afterwards, since shipping both would double it.
---
## 3. Caching and revalidation
Like SvelteKit, Remix gives you nothing here — no data cache, no cache tags. (Next has `revalidateTag`; Astro has a tagged response cache; Nitro has cached function groups.) So [`app/lib/cache.server.js`](app/lib/cache.server.js) writes one out, in about sixty lines:
- a value is **fresh** for `maxAge` seconds;
- after that it is served **stale** for up to `swr` seconds while one refresh runs behind the request, so no visitor waits on the CMS;
- concurrent misses on a cold key **share a single load**, so a burst doesn't stampede the CMS;
- a **failing background refresh keeps the stale value**, so a CMS blip doesn't take the page down;
- every entry carries **tags**, and `invalidate(tags)` drops exactly the entries that match.
Because it is hand-written rather than handed to you, those semantics are pinned by tests — [`cache.test.js`](app/lib/cache.test.js), seven of them, run with `npm test`. No test runner to install; it's `node:test`.
On top of that, each page route exports `headers` with `s-maxage` and `stale-while-revalidate`, so a CDN in front of the app can hold the rendered HTML on the same schedule.
**The webhook.** `POST /api/revalidate/all` is what your CMS calls on publish. It checks the shared secret, then drops every entry carrying one of the tags:
```bash
curl -X POST https://your-site.com/api/revalidate/all \
-H 'content-type: application/json' \
-d '{"secret":"revalidate_xxx","post":{"slug":"my-post"}}'
```
Naming a post adds its `blog-<slug>` tag to the purge, so a single article can be invalidated without dropping every listing. The response reports how many entries went. The secret is accepted in the body or as an `X-API-Key` header; anything else gets a 401, and a `GET` gets a 405.
**Before you scale out:** the cache is per-process and in-memory, so a webhook that lands on one instance only invalidates that instance. That is fine for a single server and for CDN-fronted deployments, but with several instances you want either a shared store behind the same `cached()` interface (Redis, say — the module is small and deliberately easy to swap) or a CDN purge on the same webhook.
---
## 4. SEO
- Canonical, Open Graph, Twitter card, robots and keywords come from one helper, [`buildMeta`](app/lib/seo.js), which turns the same arguments every route uses into the flat array Remix's `meta` export expects.
- `BlogPosting` and `BreadcrumbList` JSON-LD from [`app/lib/schema.js`](app/lib/schema.js).
- `/sitemap.xml` is generated from the CMS on request; `/robots.txt` follows the same switch as the meta tags. Both filenames escape the dot (`sitemap[.]xml`) so Remix treats it as a literal rather than a route separator.
- Bots get the fully rendered document rather than a streamed shell — see [`entry.server.jsx`](app/entry.server.jsx).
- Posts fall back to `/og-default.png` when they have no cover image — drop your own into `public/` before launch.
- **Indexing is off by default.** Until `PUBLIC_ALLOW_INDEXING` is exactly `"true"`, every page ships `noindex, nofollow` and `robots.txt` disallows everything — so a demo deployment can't compete with your posts' canonical home.
---
## 5. Project structure
```
app/
components/blog/ Cards, listing, hero, TOC, share bar, article detail
components/layout/ Header, footer, JSON-LD
lib/
*.server.js CMS client, config and the cache — stripped from the
browser bundle by Remix's .server.js convention
content.js Article body prep — anchors + table of contents
format.js Dates, initials, reading time
schema.js JSON-LD builders
seo.js Arguments -> Remix's meta array
site.js useSite(), reading the root loader's public config
routes/ /, /blog, /blog/$slug, /blog/category/$slug,
sitemap[.]xml, robots[.]txt, the webhook
root.jsx HTML shell, public config loader, error boundary
constants.js Values that don't vary by environment
```
---
## 6. Deploying
`npm run build` produces `build/server` and `build/client`; `remix-serve` runs them. Remix also targets Vercel, Netlify, Cloudflare, Fly and any Node host — see [Remix deployment](https://remix.run/docs/en/main/guides/deployment). Set the same environment variables there, with `PUBLIC_SITE_URL` pointing at your real domain, and point the CMS publish webhook at `https://your-domain/api/revalidate/all`.
On a serverless target, the in-memory cache lives only as long as a warm instance. There, lean on the `Cache-Control` headers each route already sets and treat the webhook as a CDN purge trigger.
---
## License
MIT — see [LICENSE](LICENSE). Clone it, strip it back, rebrand it, ship it.
Explore similar templates
Compare previews and descriptions within the same stack and category.

medusa2-starter
A Turborepo monorepo starter that pairs a Medusa2 backend with a Remix frontend, including a Coffee Roast themed storefront, Stripe payments, and Express Checkout setup notes. It supports unlimited products, collections, categories, and tags, and uses TypeScript with Biome.
Remix · E-commerce

remix-knowledge-base
A Remix-based knowledge base starter kit with Tailwind CSS and Prisma, offering Tiptap WYSIWYG and Monaco Markdown editors, multiple knowledge bases, multi-language support, drafts, SEO settings, simple statistics, and Supabase image storage.
Remix · Starter

docs-starter
A Remix-based documentation site starter with content from the Hygraph headless CMS, styled with Tailwind CSS. It supports rich text editing, automatic table of contents, custom embeds, preview mode, and SEO output.
Remix · Documentation

directed-stack
Directed Stack is an MIT-licensed Remix starter that uses Directus as a headless CMS for authentication, blog, pages, and backend storage. It includes cookie-session email/password auth, an /api/healthcheck endpoint, Tailwind, Markdoc rendering with custom components, Prettier, ESLint, and TypeScript.
Remix · Starter

remix-shadcn-docs
A Remix and Shadcn UI starter template for documentation and blogs, with TypeScript, Tailwind CSS, Markdown content directories, and built-in i18n.
Remix · Documentation

remix-wasmer-starter
A Remix-based static site starter for Wasmer Edge. It uses getStaticPaths() to generate the root path at build time, sets serverBuildTarget to "wasmer" in remix.config.js, and outputs static assets to build/client for direct static hosting.
Remix · Starter
