Open Source

Your components don't know
which CMS is running.

agnostik is a React Router starter built around one idea: your UI should have no opinion about where its data comes from. Swap CMS sources with one env var. Components never change.

What you get

No opinions — by design

3CMS sourcesSanity, PocketBase, and stub — all behind a single DataSource interface. agnostik doesn't care which one you use.
0Client waterfallsReact Router v7 framework mode. Data loads server-side — no loading spinners, no hydration surprises.
100%TypeScriptStrict mode throughout. Component prop contracts drive CMS schemas — not the other way around.
1Env var to switch CMSChange CMS_SOURCE and restart. No component changes. No migration. No ceremony.

Features

No commitment to your backend. Or your host. Or your CMS.

SanityPocketbase

One env var. Any CMS.

Swap from Sanity to PocketBase — or bring your own adapter — by changing CMS_SOURCE and restarting. Zero component changes.

React Router v7

SSR on every request

React Router v7 in framework mode. Data loads server-side — no loading spinners, no client waterfalls, no hydration mismatches.

Tailwind v4

Design system built in

Tailwind v4 with dark mode, compact mode, accent colors, and radius scale — all toggled via html class flags with no JavaScript overhead.

TypeScript strict

Components that don't know

Three layers: primitives → blocks → sections. Components receive normalised data and render it. They have no knowledge of — and no opinion about — the source.

Get started in minutes

Clone the repo, install dependencies, and copy the example env file:

git clone https://github.com/your-org/react-router-boilerplate my-site
cd my-site && npm install
cp .env.example .env

Set CMS_SOURCE=stub in .env to run with no external dependencies — no API keys, no credentials, no setup required:

npm run dev

The dev server starts at http://localhost:5173. All data comes from stub.json until you connect a live CMS.

Architecture

Under the hood

SSR

Rendering strategy

Every page load goes server-side. No stale client cache, no rehydration flash.

4

Steps to a new section

Type → Component → Register → Data. The renderer handles the rest.

DataSource interface

Two methods — getPage and listPages. That's the entire contract. The rest of the app is genuinely agnostic: it never knows, and never needs to know, which backend it's talking to.

Section registry

One entry maps a componentType string to a React component. Add it once and it's immediately available in the renderer, stories, and documentation.

3

Component layers

Primitives, blocks, and sections with hard boundaries. Editors interact only with sections.

1

Catch-all route

$.tsx handles every CMS-driven page. No new route files needed for new content.

Philosophy

Noncommittal by design

Most starters commit early. agnostik commits to as little as possible — so you can commit to whatever you need.

The name comes from the Greek agnōstos — "not known." That's literally what's happening: your components receive normalised data and render it. They have no knowledge of, and no opinion about, which CMS produced it. Swap the source and they keep working. They never knew.

What agnostik refuses to commit to

  • Your CMS — Sanity, PocketBase, or bring your own. Implement two methods, set an env var, and the rest of the codebase never changes.
  • Your content model — Components define their prop contracts. CMS schemas follow those contracts — not the other way around.
  • Your design system — CSS tokens, not component logic. Dark mode, compact mode, accent colors — all toggled by a class on the html element.
  • Your host — SSR on any Node server. Fly.io, Railway, Render, bare metal. No platform-specific adapter needed.

Common questions